/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.kafka.coordinator.group;

import org.apache.kafka.clients.consumer.ConsumerPartitionAssignor;
import org.apache.kafka.clients.consumer.internals.ConsumerProtocol;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.Uuid;
import org.apache.kafka.common.config.AbstractConfig;
import org.apache.kafka.common.errors.CoordinatorNotAvailableException;
import org.apache.kafka.common.errors.FencedInstanceIdException;
import org.apache.kafka.common.errors.FencedMemberEpochException;
import org.apache.kafka.common.errors.GroupIdNotFoundException;
import org.apache.kafka.common.errors.GroupMaxSizeReachedException;
import org.apache.kafka.common.errors.IllegalGenerationException;
import org.apache.kafka.common.errors.InconsistentGroupProtocolException;
import org.apache.kafka.common.errors.InvalidRegularExpression;
import org.apache.kafka.common.errors.InvalidRequestException;
import org.apache.kafka.common.errors.NotLeaderOrFollowerException;
import org.apache.kafka.common.errors.RebalanceInProgressException;
import org.apache.kafka.common.errors.UnknownMemberIdException;
import org.apache.kafka.common.errors.UnknownServerException;
import org.apache.kafka.common.errors.UnknownTopicOrPartitionException;
import org.apache.kafka.common.errors.UnreleasedInstanceIdException;
import org.apache.kafka.common.internals.Plugin;
import org.apache.kafka.common.message.ConsumerGroupDescribeResponseData;
import org.apache.kafka.common.message.ConsumerGroupHeartbeatRequestData;
import org.apache.kafka.common.message.ConsumerGroupHeartbeatResponseData;
import org.apache.kafka.common.message.ConsumerProtocolAssignment;
import org.apache.kafka.common.message.ConsumerProtocolSubscription;
import org.apache.kafka.common.message.CreateTopicsRequestData.CreatableTopic;
import org.apache.kafka.common.message.DeleteShareGroupOffsetsRequestData;
import org.apache.kafka.common.message.DeleteShareGroupOffsetsResponseData;
import org.apache.kafka.common.message.DeleteShareGroupStateRequestData;
import org.apache.kafka.common.message.DescribeGroupsResponseData;
import org.apache.kafka.common.message.HeartbeatRequestData;
import org.apache.kafka.common.message.HeartbeatResponseData;
import org.apache.kafka.common.message.JoinGroupRequestData;
import org.apache.kafka.common.message.JoinGroupRequestData.JoinGroupRequestProtocol;
import org.apache.kafka.common.message.JoinGroupRequestData.JoinGroupRequestProtocolCollection;
import org.apache.kafka.common.message.JoinGroupResponseData;
import org.apache.kafka.common.message.JoinGroupResponseData.JoinGroupResponseMember;
import org.apache.kafka.common.message.LeaveGroupRequestData;
import org.apache.kafka.common.message.LeaveGroupRequestData.MemberIdentity;
import org.apache.kafka.common.message.LeaveGroupResponseData;
import org.apache.kafka.common.message.ListGroupsResponseData;
import org.apache.kafka.common.message.ShareGroupDescribeResponseData;
import org.apache.kafka.common.message.ShareGroupHeartbeatRequestData;
import org.apache.kafka.common.message.ShareGroupHeartbeatResponseData;
import org.apache.kafka.common.message.StreamsGroupDescribeResponseData;
import org.apache.kafka.common.message.StreamsGroupHeartbeatRequestData;
import org.apache.kafka.common.message.StreamsGroupHeartbeatRequestData.CopartitionGroup;
import org.apache.kafka.common.message.StreamsGroupHeartbeatRequestData.Subtopology;
import org.apache.kafka.common.message.StreamsGroupHeartbeatRequestData.TopicInfo;
import org.apache.kafka.common.message.StreamsGroupHeartbeatRequestData.Topology;
import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
import org.apache.kafka.common.message.SyncGroupRequestData;
import org.apache.kafka.common.message.SyncGroupRequestData.SyncGroupRequestAssignment;
import org.apache.kafka.common.message.SyncGroupResponseData;
import org.apache.kafka.common.metadata.PartitionRecord;
import org.apache.kafka.common.metadata.RemoveTopicRecord;
import org.apache.kafka.common.metadata.TopicRecord;
import org.apache.kafka.common.protocol.ApiKeys;
import org.apache.kafka.common.protocol.Errors;
import org.apache.kafka.common.requests.StreamsGroupHeartbeatResponse.Status;
import org.apache.kafka.common.utils.LogContext;
import org.apache.kafka.common.utils.MockTime;
import org.apache.kafka.common.utils.Utils;
import org.apache.kafka.coordinator.common.runtime.CoordinatorMetadataDelta;
import org.apache.kafka.coordinator.common.runtime.CoordinatorMetadataImage;
import org.apache.kafka.coordinator.common.runtime.CoordinatorRecord;
import org.apache.kafka.coordinator.common.runtime.CoordinatorResult;
import org.apache.kafka.coordinator.common.runtime.KRaftCoordinatorMetadataDelta;
import org.apache.kafka.coordinator.common.runtime.KRaftCoordinatorMetadataImage;
import org.apache.kafka.coordinator.common.runtime.MetadataImageBuilder;
import org.apache.kafka.coordinator.common.runtime.MockCoordinatorExecutor;
import org.apache.kafka.coordinator.common.runtime.MockCoordinatorTimer;
import org.apache.kafka.coordinator.common.runtime.MockCoordinatorTimer.ExpiredTimeout;
import org.apache.kafka.coordinator.common.runtime.MockCoordinatorTimer.ScheduledTimeout;
import org.apache.kafka.coordinator.group.api.assignor.ConsumerGroupPartitionAssignor;
import org.apache.kafka.coordinator.group.api.assignor.GroupAssignment;
import org.apache.kafka.coordinator.group.api.assignor.GroupSpec;
import org.apache.kafka.coordinator.group.api.assignor.PartitionAssignorException;
import org.apache.kafka.coordinator.group.classic.ClassicGroup;
import org.apache.kafka.coordinator.group.classic.ClassicGroupMember;
import org.apache.kafka.coordinator.group.classic.ClassicGroupState;
import org.apache.kafka.coordinator.group.generated.ConsumerGroupMemberMetadataValue;
import org.apache.kafka.coordinator.group.generated.ConsumerGroupPartitionMetadataKey;
import org.apache.kafka.coordinator.group.generated.ConsumerGroupPartitionMetadataValue;
import org.apache.kafka.coordinator.group.generated.GroupMetadataValue;
import org.apache.kafka.coordinator.group.generated.ShareGroupCurrentMemberAssignmentKey;
import org.apache.kafka.coordinator.group.generated.ShareGroupCurrentMemberAssignmentValue;
import org.apache.kafka.coordinator.group.generated.ShareGroupMemberMetadataKey;
import org.apache.kafka.coordinator.group.generated.ShareGroupMemberMetadataValue;
import org.apache.kafka.coordinator.group.generated.ShareGroupMetadataKey;
import org.apache.kafka.coordinator.group.generated.ShareGroupMetadataValue;
import org.apache.kafka.coordinator.group.generated.ShareGroupStatePartitionMetadataKey;
import org.apache.kafka.coordinator.group.generated.ShareGroupStatePartitionMetadataValue;
import org.apache.kafka.coordinator.group.generated.ShareGroupTargetAssignmentMemberKey;
import org.apache.kafka.coordinator.group.generated.ShareGroupTargetAssignmentMemberValue;
import org.apache.kafka.coordinator.group.generated.ShareGroupTargetAssignmentMetadataKey;
import org.apache.kafka.coordinator.group.generated.ShareGroupTargetAssignmentMetadataValue;
import org.apache.kafka.coordinator.group.generated.StreamsGroupMemberMetadataValue.Endpoint;
import org.apache.kafka.coordinator.group.generated.StreamsGroupMetadataKey;
import org.apache.kafka.coordinator.group.generated.StreamsGroupMetadataValue;
import org.apache.kafka.coordinator.group.generated.StreamsGroupTopologyValue;
import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetricsShard;
import org.apache.kafka.coordinator.group.modern.Assignment;
import org.apache.kafka.coordinator.group.modern.MemberAssignmentImpl;
import org.apache.kafka.coordinator.group.modern.MemberState;
import org.apache.kafka.coordinator.group.modern.SubscriptionCount;
import org.apache.kafka.coordinator.group.modern.consumer.ConsumerGroup;
import org.apache.kafka.coordinator.group.modern.consumer.ConsumerGroupBuilder;
import org.apache.kafka.coordinator.group.modern.consumer.ConsumerGroupMember;
import org.apache.kafka.coordinator.group.modern.consumer.ResolvedRegularExpression;
import org.apache.kafka.coordinator.group.modern.share.ShareGroup;
import org.apache.kafka.coordinator.group.modern.share.ShareGroup.InitMapValue;
import org.apache.kafka.coordinator.group.modern.share.ShareGroupBuilder;
import org.apache.kafka.coordinator.group.modern.share.ShareGroupConfig;
import org.apache.kafka.coordinator.group.modern.share.ShareGroupMember;
import org.apache.kafka.coordinator.group.streams.MockTaskAssignor;
import org.apache.kafka.coordinator.group.streams.StreamsCoordinatorRecordHelpers;
import org.apache.kafka.coordinator.group.streams.StreamsGroup;
import org.apache.kafka.coordinator.group.streams.StreamsGroup.StreamsGroupState;
import org.apache.kafka.coordinator.group.streams.StreamsGroupBuilder;
import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult;
import org.apache.kafka.coordinator.group.streams.StreamsGroupMember;
import org.apache.kafka.coordinator.group.streams.StreamsTopology;
import org.apache.kafka.coordinator.group.streams.TaskAssignmentTestUtil;
import org.apache.kafka.coordinator.group.streams.TaskAssignmentTestUtil.TaskRole;
import org.apache.kafka.coordinator.group.streams.TasksTuple;
import org.apache.kafka.coordinator.group.streams.TasksTupleWithEpochs;
import org.apache.kafka.coordinator.group.streams.assignor.TaskAssignor;
import org.apache.kafka.coordinator.group.streams.assignor.TaskAssignorException;
import org.apache.kafka.image.MetadataDelta;
import org.apache.kafka.image.MetadataImage;
import org.apache.kafka.image.MetadataProvenance;
import org.apache.kafka.server.authorizer.Action;
import org.apache.kafka.server.authorizer.AuthorizationResult;
import org.apache.kafka.server.authorizer.Authorizer;
import org.apache.kafka.server.common.ApiMessageAndVersion;
import org.apache.kafka.server.share.persister.DeleteShareGroupStateParameters;
import org.apache.kafka.server.share.persister.InitializeShareGroupStateParameters;
import org.apache.kafka.server.share.persister.PartitionIdData;
import org.apache.kafka.server.share.persister.PartitionStateData;
import org.apache.kafka.server.share.persister.TopicData;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import static org.apache.kafka.common.protocol.Errors.NOT_COORDINATOR;
import static org.apache.kafka.common.requests.ConsumerGroupHeartbeatRequest.LEAVE_GROUP_MEMBER_EPOCH;
import static org.apache.kafka.common.requests.ConsumerGroupHeartbeatRequest.LEAVE_GROUP_STATIC_MEMBER_EPOCH;
import static org.apache.kafka.common.requests.JoinGroupRequest.UNKNOWN_MEMBER_ID;
import static org.apache.kafka.coordinator.group.Assertions.assertRecordEquals;
import static org.apache.kafka.coordinator.group.Assertions.assertRecordsEquals;
import static org.apache.kafka.coordinator.group.Assertions.assertResponseEquals;
import static org.apache.kafka.coordinator.group.Assertions.assertUnorderedRecordsEquals;
import static org.apache.kafka.coordinator.group.AssignmentTestUtil.mkAssignment;
import static org.apache.kafka.coordinator.group.AssignmentTestUtil.mkAssignmentWithEpochs;
import static org.apache.kafka.coordinator.group.AssignmentTestUtil.mkTopicAssignment;
import static org.apache.kafka.coordinator.group.AssignmentTestUtil.mkTopicAssignmentWithEpochs;
import static org.apache.kafka.coordinator.group.GroupConfig.CONSUMER_HEARTBEAT_INTERVAL_MS_CONFIG;
import static org.apache.kafka.coordinator.group.GroupConfig.CONSUMER_SESSION_TIMEOUT_MS_CONFIG;
import static org.apache.kafka.coordinator.group.GroupConfig.SHARE_HEARTBEAT_INTERVAL_MS_CONFIG;
import static org.apache.kafka.coordinator.group.GroupConfig.SHARE_SESSION_TIMEOUT_MS_CONFIG;
import static org.apache.kafka.coordinator.group.GroupConfig.STREAMS_HEARTBEAT_INTERVAL_MS_CONFIG;
import static org.apache.kafka.coordinator.group.GroupConfig.STREAMS_NUM_STANDBY_REPLICAS_CONFIG;
import static org.apache.kafka.coordinator.group.GroupConfig.STREAMS_SESSION_TIMEOUT_MS_CONFIG;
import static org.apache.kafka.coordinator.group.GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord;
import static org.apache.kafka.coordinator.group.GroupMetadataManager.EMPTY_RESULT;
import static org.apache.kafka.coordinator.group.GroupMetadataManager.appendGroupMetadataErrorToResponseError;
import static org.apache.kafka.coordinator.group.GroupMetadataManager.classicGroupHeartbeatKey;
import static org.apache.kafka.coordinator.group.GroupMetadataManager.classicGroupJoinKey;
import static org.apache.kafka.coordinator.group.GroupMetadataManager.classicGroupSyncKey;
import static org.apache.kafka.coordinator.group.GroupMetadataManager.consumerGroupJoinKey;
import static org.apache.kafka.coordinator.group.GroupMetadataManager.groupRebalanceTimeoutKey;
import static org.apache.kafka.coordinator.group.GroupMetadataManager.groupSessionTimeoutKey;
import static org.apache.kafka.coordinator.group.GroupMetadataManagerTestContext.DEFAULT_CLIENT_ADDRESS;
import static org.apache.kafka.coordinator.group.GroupMetadataManagerTestContext.DEFAULT_CLIENT_ID;
import static org.apache.kafka.coordinator.group.GroupMetadataManagerTestContext.DEFAULT_PROCESS_ID;
import static org.apache.kafka.coordinator.group.Utils.computeGroupHash;
import static org.apache.kafka.coordinator.group.Utils.computeTopicHash;
import static org.apache.kafka.coordinator.group.Utils.toAssignmentWithEpochs;
import static org.apache.kafka.coordinator.group.Utils.toAssignmentWithoutEpochs;
import static org.apache.kafka.coordinator.group.classic.ClassicGroupMember.EMPTY_ASSIGNMENT;
import static org.apache.kafka.coordinator.group.classic.ClassicGroupState.COMPLETING_REBALANCE;
import static org.apache.kafka.coordinator.group.classic.ClassicGroupState.DEAD;
import static org.apache.kafka.coordinator.group.classic.ClassicGroupState.EMPTY;
import static org.apache.kafka.coordinator.group.classic.ClassicGroupState.PREPARING_REBALANCE;
import static org.apache.kafka.coordinator.group.classic.ClassicGroupState.STABLE;
import static org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics.CLASSIC_GROUP_COMPLETED_REBALANCES_SENSOR_NAME;
import static org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics.CONSUMER_GROUP_REBALANCES_SENSOR_NAME;
import static org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics.SHARE_GROUP_REBALANCES_SENSOR_NAME;
import static org.apache.kafka.coordinator.group.streams.TaskAssignmentTestUtil.mkTasksTupleWithCommonEpoch;
import static org.apache.kafka.coordinator.group.streams.TaskAssignmentTestUtil.mkTasksTupleWithEpochs;
import static org.apache.kafka.coordinator.group.streams.TaskAssignmentTestUtil.mkTasksWithEpochs;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

public class GroupMetadataManagerTest {

    @Test
    public void testConsumerHeartbeatRegexValidation() {
        String memberId = Uuid.randomUuid().toString();
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .build();

        // Subscribing with an invalid regular expression fails.
        Exception ex = assertThrows(InvalidRegularExpression.class, () -> context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setMemberId(Uuid.randomUuid().toString())
                .setGroupId("foo")
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("[")
                .setTopicPartitions(List.of())));
        assertEquals("SubscribedTopicRegex `[` is not a valid regular expression: missing closing ].", ex.getMessage());

        // Subscribing with a valid regular expression succeeds.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId("foo")
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex(".*")
                .setTopicPartitions(List.of()));
        assertEquals(1, result.response().memberEpoch());

        // Updating the subscription to an invalid regular expression fails.
        assertThrows(InvalidRegularExpression.class, () -> context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId("foo")
                .setMemberId(memberId)
                .setMemberEpoch(1)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("[")
                .setTopicPartitions(List.of())));
        assertEquals("SubscribedTopicRegex `[` is not a valid regular expression: missing closing ].", ex.getMessage());

        // Updating the subscription to topic names succeeds (checking when the regex becomes null).
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId("foo")
                .setMemberId(memberId)
                .setMemberEpoch(1)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo"))
                .setTopicPartitions(List.of()));
        assertEquals(2, result.response().memberEpoch());
    }

    @Test
    public void testMemberIdGeneration() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of()
        ));

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            // The consumer generates its own Member ID starting from version 1 of the ConsumerGroupHeartbeat RPC.
            // Therefore, this test case is specific to earlier versions of the RPC.
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId("group-foo")
                .setMemberEpoch(0)
                .setServerAssignor("range")
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()),
            (short) 0
        );

        // Verify that a member id was generated for the new member.
        String memberId = result.response().memberId();
        assertNotNull(memberId);
        assertNotEquals("", memberId);

        // The response should get a bumped epoch and should not
        // contain any assignment because we did not provide
        // topics metadata.
        assertEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()),
            result.response()
        );
    }

    @Test
    public void testUnknownGroupId() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .build();

        assertThrows(GroupIdNotFoundException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(100) // Epoch must be > 0.
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setTopicPartitions(List.of())));
    }

    @Test
    public void testUnknownMemberIdJoinsConsumerGroup() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .build();

        // A first member joins to create the group.
        context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setServerAssignor(NoOpPartitionAssignor.NAME)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        // The second member is rejected because the member id is unknown and
        // the member epoch is not zero.
        assertThrows(UnknownMemberIdException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(Uuid.randomUuid().toString())
                    .setMemberEpoch(1)
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setTopicPartitions(List.of())));
    }

    @Test
    public void testConsumerGroupMemberEpochValidation() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .build();

        ConsumerGroupMember member = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 1, 2, 3)), 100))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, member));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 100, 0));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
            mkTopicAssignment(fooTopicId, 1, 2, 3)
        )));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 100, 12345L));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member));

        // Member epoch is greater than the expected epoch.
        assertThrows(FencedMemberEpochException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(200)
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))));

        // Member epoch is smaller than the expected epoch.
        assertThrows(FencedMemberEpochException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(50)
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))));

        // Member joins with previous epoch but without providing partitions.
        assertThrows(FencedMemberEpochException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(99)
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))));

        // Member joins with previous epoch and has a subset of the owned partitions. This
        // is accepted as the response with the bumped epoch may have been lost. In this
        // case, we provide back the correct epoch to the member.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(99)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of(new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(fooTopicId)
                    .setPartitions(List.of(1, 2)))));
        assertEquals(100, result.response().memberEpoch());
    }

    @Test
    public void testMemberCanRejoinWithEpochZeroInStableState() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Member is in STABLE state with epoch 100.
        ConsumerGroupMember member = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0, 1, 2)), 100))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, member));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 100, computeGroupHash(Map.of(
            fooTopicName, fooTopicHash
        ))));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
            mkTopicAssignment(fooTopicId, 0, 1, 2)
        )));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 100, 12345L));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member));

        assertEquals(MemberState.STABLE, context.consumerGroupMemberState(groupId, memberId));

        // Member rejoins with epoch=0 - should succeed per KIP-848.
        // Since the member is STABLE with the same subscription and assignment,
        // the group epoch should not bump and the member gets their current state back.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(100)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2))))),
            result.response()
        );
    }

    @Test
    public void testMemberCanRejoinWithEpochZeroInUnrevokedPartitionsState() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Member is in UNREVOKED_PARTITIONS state with epoch 100.
        // The group has advanced to epoch 101 with a new target assignment [0, 1].
        // The member still has partition 2 pending revocation.
        ConsumerGroupMember member = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.UNREVOKED_PARTITIONS)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0, 1)), 11))
            .setPartitionsPendingRevocation(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 2)), 11))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, member));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 101, computeGroupHash(Map.of(
            fooTopicName, fooTopicHash
        ))));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
            mkTopicAssignment(fooTopicId, 0, 1)
        )));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 101, 12345L));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member));

        assertEquals(MemberState.UNREVOKED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId));

        // Member rejoins with epoch=0 - should succeed per KIP-848.
        // The member advances to epoch 101 and gets their target assignment [0, 1].
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(101)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1))))),
            result.response()
        );
    }

    @Test
    public void testMemberCanRejoinWithEpochZeroInUnreleasedPartitionsState() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Member is in UNRELEASED_PARTITIONS state with epoch 100.
        ConsumerGroupMember member = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0)), 11))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, member));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 100, computeGroupHash(Map.of(
            fooTopicName, fooTopicHash
        ))));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
            mkTopicAssignment(fooTopicId, 0, 1, 2)
        )));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 100, 12345L));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member));

        assertEquals(MemberState.UNRELEASED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId));

        // Member rejoins with epoch=0 - should succeed per KIP-848.
        // Since the subscription/metadata hasn't changed, group epoch stays at 100.
        // The member gets the target assignment [0, 1, 2].
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(100)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2))))),
            result.response()
        );
    }

    @Test
    public void testDuplicateFullHeartbeatInStableState() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Member is in STABLE state with epoch 100.
        ConsumerGroupMember member = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0, 1, 2)), 100))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, member));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 100, computeGroupHash(Map.of(
            fooTopicName, fooTopicHash
        ))));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
            mkTopicAssignment(fooTopicId, 0, 1, 2)
        )));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 100, 12345L));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member));

        assertEquals(MemberState.STABLE, context.consumerGroupMemberState(groupId, memberId));

        // Create full request with current epoch.
        ConsumerGroupHeartbeatRequestData fullRequest = new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId)
            .setMemberEpoch(100)
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignor("range")
            .setTopicPartitions(List.of(
                new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(fooTopicId)
                    .setPartitions(List.of(0, 1, 2))));

        // First heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result1 =
            context.consumerGroupHeartbeat(fullRequest);

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(100)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2))))),
            result1.response()
        );

        // Duplicate heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result2 =
            context.consumerGroupHeartbeat(fullRequest);

        // Verify duplicate produces same response with no records.
        assertResponseEquals(result1.response(), result2.response());
        assertEquals(List.of(), result2.records());
        assertEquals(MemberState.STABLE, context.consumerGroupMemberState(groupId, memberId));
    }

    @Test
    public void testDuplicateFullHeartbeatInUnrevokedPartitionsState() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Member is in UNREVOKED_PARTITIONS state with epoch 100.
        // Target assignment is [0, 1], but member still owns [0, 1, 2].
        ConsumerGroupMember member = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.UNREVOKED_PARTITIONS)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0, 1)), 11))
            .setPartitionsPendingRevocation(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 2)), 11))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, member));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 101, computeGroupHash(Map.of(
            fooTopicName, fooTopicHash
        ))));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
            mkTopicAssignment(fooTopicId, 0, 1)
        )));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 101, 12345L));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member));

        assertEquals(MemberState.UNREVOKED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId));

        // Create full request with current epoch. Member still reports owning all partitions.
        ConsumerGroupHeartbeatRequestData fullRequest = new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId)
            .setMemberEpoch(100)
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignor("range")
            .setTopicPartitions(List.of(
                new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(fooTopicId)
                    .setPartitions(List.of(0, 1, 2))));

        // First heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result1 =
            context.consumerGroupHeartbeat(fullRequest);

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(100)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1))))),
            result1.response()
        );

        assertEquals(MemberState.UNREVOKED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId));

        // Duplicate heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result2 =
            context.consumerGroupHeartbeat(fullRequest);

        // Verify duplicate produces same response with no records.
        assertResponseEquals(result1.response(), result2.response());
        assertEquals(List.of(), result2.records());
        assertEquals(MemberState.UNREVOKED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId));
    }

    @Test
    public void testDuplicateFullHeartbeatInUnreleasedPartitionsState() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Member 1 is in UNRELEASED_PARTITIONS state with epoch 100.
        // Member 1 has [0] assigned but target is [0, 1, 2].
        // Member 2 still owns [1, 2] and needs to revoke them.
        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0)), 11))
            .build();

        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.UNREVOKED_PARTITIONS)
            .setMemberEpoch(99)
            .setPreviousMemberEpoch(98)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(Map.of())
            .setPartitionsPendingRevocation(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 1, 2)), 11))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, member1));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, member2));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 100, computeGroupHash(Map.of(
            fooTopicName, fooTopicHash
        ))));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
            mkTopicAssignment(fooTopicId, 0, 1, 2)
        )));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, mkAssignment()));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 100, 12345L));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member1));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member2));

        assertEquals(MemberState.UNRELEASED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId1));

        // Create full request with current epoch.
        ConsumerGroupHeartbeatRequestData fullRequest = new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId1)
            .setMemberEpoch(100)
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignor("range")
            .setTopicPartitions(List.of(
                new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(fooTopicId)
                    .setPartitions(List.of(0))));

        // First heartbeat. Member is UNRELEASED_PARTITIONS so response includes current assignment.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result1 =
            context.consumerGroupHeartbeat(fullRequest);

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(100)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0))))),
            result1.response()
        );

        assertEquals(MemberState.UNRELEASED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId1));

        // Duplicate heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result2 =
            context.consumerGroupHeartbeat(fullRequest);

        // Verify duplicate produces same response with no records.
        assertResponseEquals(result1.response(), result2.response());
        assertEquals(List.of(), result2.records());
        assertEquals(MemberState.UNRELEASED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId1));
    }

    @Test
    public void testDuplicateFullHeartbeatWithRevocationAck() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Member is in UNREVOKED_PARTITIONS state with epoch 100.
        // Target assignment is [0, 1], member needs to revoke [2].
        ConsumerGroupMember member = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.UNREVOKED_PARTITIONS)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0, 1)), 11))
            .setPartitionsPendingRevocation(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 2)), 11))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, member));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 101, computeGroupHash(Map.of(
            fooTopicName, fooTopicHash
        ))));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
            mkTopicAssignment(fooTopicId, 0, 1)
        )));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 101, 12345L));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member));

        assertEquals(MemberState.UNREVOKED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId));

        // Create full request acknowledging revocation (only owns [0, 1]).
        ConsumerGroupHeartbeatRequestData fullRequest = new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId)
            .setMemberEpoch(100)
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignor("range")
            .setTopicPartitions(List.of(
                new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(fooTopicId)
                    .setPartitions(List.of(0, 1))));

        // First heartbeat acknowledges revocation and transitions to STABLE.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result1 =
            context.consumerGroupHeartbeat(fullRequest);

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(101)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1))))),
            result1.response()
        );

        assertEquals(MemberState.STABLE, context.consumerGroupMemberState(groupId, memberId));

        // Duplicate heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result2 =
            context.consumerGroupHeartbeat(fullRequest);

        // Verify duplicate produces same response with no records.
        assertResponseEquals(result1.response(), result2.response());
        assertEquals(List.of(), result2.records());
        assertEquals(MemberState.STABLE, context.consumerGroupMemberState(groupId, memberId));
    }

    @Test
    public void testShareGroupMemberCanRejoinWithEpochZero() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(metadataImage)
            .build();

        // Set up a Share group member with epoch 100.
        ShareGroupMember member = new ShareGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setAssignedPartitions(mkAssignment(mkTopicAssignment(fooTopicId, 0, 1, 2)))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupMemberSubscriptionRecord(groupId, member));
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 100, computeGroupHash(Map.of(
            fooTopicName, fooTopicHash
        ))));
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
            mkTopicAssignment(fooTopicId, 0, 1, 2)
        )));
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentMetadataRecord(groupId, 100, 12345L));
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupCurrentAssignmentRecord(groupId, member));

        // Member rejoins with epoch=0 - should succeed.
        // Since the subscription/metadata hasn't changed, group epoch stays at 100.
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of(fooTopicName)));

        assertEquals(
            new ShareGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(100)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ShareGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ShareGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2))))),
            result.response().getKey()
        );
    }

    @Test
    public void testStreamsGroupMemberCanRejoinWithEpochZero() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Topology topology = new Topology()
            .setEpoch(1)
            .setSubtopologies(List.of(
                new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
            ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Set up a Streams group member with epoch 100.
        StreamsGroupMember member = streamsGroupMemberBuilderWithDefaults(memberId)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setTopologyEpoch(1)
            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 100, TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
            .build();

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, member));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord(groupId, topology));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(groupId, 100, computeGroupHash(Map.of(
            fooTopicName, fooTopicHash
        )), 1, Map.of("num.standby.replicas", "0")));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId,
            TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)
            )));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 100, 12345L));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, member));

        // Member rejoins with epoch=0 - should succeed per KIP-848.
        // Since the topology/metadata hasn't changed, group epoch stays at 100.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(100)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1, 2))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );
    }

    @Test
    public void testMemberJoinsEmptyConsumerGroup() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            )))
        ));

        assertThrows(GroupIdNotFoundException.class, () ->
            context.groupMetadataManager.consumerGroup(groupId));

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setServerAssignor("range")
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5)),
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(0, 1, 2))
                    ))),
            result.response()
        );

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)), 2))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 2, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                barTopicName, computeTopicHash(barTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            )),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testTopicHashIsRemoveFromCacheIfNoGroupSubscribesIt() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addRacks()
            .buildCoordinatorMetadataImage();
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setServerAssignor("range")
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                    ))),
            result.response()
        );

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 2))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 2, computeGroupHash(Map.of(
                fooTopicName, fooTopicHash
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
        assertEquals(Map.of(fooTopicName, fooTopicHash), context.groupMetadataManager.topicHashCache());

        // Use LEAVE_GROUP_MEMBER_EPOCH to leave group, so there is no group subscribes to foo.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(-1)
                .setHeartbeatIntervalMs(0),
            result.response()
        );

        expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 3, 0)
        );
        assertRecordsEquals(expectedRecords, result.records());
        assertEquals(Map.of(), context.groupMetadataManager.topicHashCache());
    }

    @Test
    public void testUpdatingSubscriptionTriggersNewTargetAssignment() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(fooTopicName, fooTopicHash))))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            )))
        ));

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10)
                .setSubscribedTopicNames(List.of("foo", "bar")));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5)),
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(0, 1, 2))
                    ))),
            result.response()
        );

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(mkAssignmentWithEpochs(
                mkTopicAssignmentWithEpochs(fooTopicId, 10, 0, 1, 2, 3, 4, 5),
                mkTopicAssignmentWithEpochs(barTopicId, 11, 0, 1, 2)))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, fooTopicHash,
                barTopicName, barTopicHash
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            )),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testNewRacksDataInMetadataImageTriggersEpochBump() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .build();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage)))
                )))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        // Update metadata image with racks.
        CoordinatorMetadataImage newMetadataImage = new MetadataImageBuilder(metadataImage)
            .addTopic(fooTopicId, fooTopicName, 6)
            .addRacks()
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(
            newMetadataImage.emptyDelta(), newMetadataImage
        );
        // If a topic is updated, related topic hash is cleanup.
        assertEquals(Map.of(), context.groupMetadataManager.topicHashCache());

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000),
            result.response()
        );

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, newMetadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testRemoveTopicCleanupTopicHash() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .build();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName,
                    computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage)))
                )))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(Map.of()))
        ));

        // Remove foo topic from metadata image.
        MetadataDelta delta = new MetadataDelta.Builder()
            .setImage(metadataImage)
            .build();
        delta.replay(new RemoveTopicRecord().setTopicId(fooTopicId));
        MetadataImage newMetadataImage = delta.apply(MetadataProvenance.EMPTY);

        context.groupMetadataManager.onMetadataUpdate(
            new KRaftCoordinatorMetadataDelta(new MetadataDelta.Builder()
                .setImage(newMetadataImage)
                .build()), new KRaftCoordinatorMetadataImage(newMetadataImage)
        );
        // If a topic is removed, related topic hash is cleanup.
        assertEquals(Map.of(), context.groupMetadataManager.topicHashCache());

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()),
            result.response()
        );

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.UNREVOKED_PARTITIONS)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setPartitionsPendingRevocation(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, 0),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, Map.of()),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testSubscriptionUpgradeToMetadataHash() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addRacks()
            .build();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignmentEpoch(10))
            .build();

        ConsumerGroupPartitionMetadataValue consumerGroupPartitionMetadataValue = new ConsumerGroupPartitionMetadataValue();
        consumerGroupPartitionMetadataValue.topics().add(new ConsumerGroupPartitionMetadataValue.TopicMetadata()
            .setTopicId(fooTopicId)
            .setTopicName(fooTopicName)
            .setNumPartitions(6));
        context.replay(CoordinatorRecord.record(
            new ConsumerGroupPartitionMetadataKey().setGroupId(groupId),
            new ApiMessageAndVersion(consumerGroupPartitionMetadataValue, (short) 0)
        ));
        context.commit();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000),
            result.response()
        );

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage))
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord(groupId),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testNewJoiningMemberTriggersNewTargetAssignment() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .build();
        long groupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage)),
            barTopicName, computeTopicHash(barTopicName, new KRaftCoordinatorMetadataImage(metadataImage))
        ));

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2),
                        mkTopicAssignment(barTopicId, 0, 1)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5),
                        mkTopicAssignment(barTopicId, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(groupMetadataHash))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1),
                mkTopicAssignment(barTopicId, 0)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 2, 3),
                mkTopicAssignment(barTopicId, 1)
            )),
            memberId3, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 4, 5),
                mkTopicAssignment(barTopicId, 2)
            ))
        )));

        // Member 3 joins the consumer group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId3)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()),
            result.response()
        );

        ConsumerGroupMember expectedMember3 = new ConsumerGroupMember.Builder(memberId3)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .build();

        assertUnorderedRecordsEquals(
            List.of(
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember3)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, groupMetadataHash)),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1),
                        mkTopicAssignment(barTopicId, 0)
                    )),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, mkAssignment(
                        mkTopicAssignment(fooTopicId, 2, 3),
                        mkTopicAssignment(barTopicId, 1)
                    )),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId3, mkAssignment(
                        mkTopicAssignment(fooTopicId, 4, 5),
                        mkTopicAssignment(barTopicId, 2)
                    ))
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds())),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember3))
            ),
            result.records()
        );
    }

    @Test
    public void testLeavingMemberBumpsGroupEpoch() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Uuid zarTopicId = Uuid.randomUuid();
        String zarTopicName = "zar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addTopic(zarTopicId, zarTopicName, 1)
            .addRacks()
            .build();
        long fooTopicHash = computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage));
        long barTopicHash = computeTopicHash(barTopicName, new KRaftCoordinatorMetadataImage(metadataImage));
        long zarTopicHash = computeTopicHash(zarTopicName, new KRaftCoordinatorMetadataImage(metadataImage));

        // Consumer group with two members.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2),
                        mkTopicAssignment(barTopicId, 0, 1)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    // Use zar only here to ensure that metadata needs to be recomputed.
                    .setSubscribedTopicNames(List.of("foo", "bar", "zar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5),
                        mkTopicAssignment(barTopicId, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash,
                    zarTopicName, zarTopicHash
                ))))
            .build();

        // Member 2 leaves the consumer group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH),
            result.response()
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2),
            // Group metadata hash is recomputed because zar is no longer there.
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, fooTopicHash,
                barTopicName, barTopicHash
            )))
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testGroupEpochBumpWhenNewStaticMemberJoins() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .build();
        long groupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage)),
            barTopicName, computeTopicHash(barTopicName, new KRaftCoordinatorMetadataImage(metadataImage))
        ));

        // Consumer group with two static members.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2),
                        mkTopicAssignment(barTopicId, 0, 1)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId2)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    // Use zar only here to ensure that metadata needs to be recomputed.
                    .setSubscribedTopicNames(List.of("foo", "bar", "zar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5),
                        mkTopicAssignment(barTopicId, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(groupMetadataHash))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1),
                mkTopicAssignment(barTopicId, 0)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 2, 3),
                mkTopicAssignment(barTopicId, 1)
            )),
            memberId3, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 4, 5),
                mkTopicAssignment(barTopicId, 2)
            ))
        )));

        // Member 3 joins the consumer group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId3)
                .setInstanceId(memberId3)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()),
            result.response()
        );

        ConsumerGroupMember expectedMember3 = new ConsumerGroupMember.Builder(memberId3)
            .setMemberEpoch(11)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setInstanceId(memberId3)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .build();

        assertUnorderedRecordsEquals(
            List.of(
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember3)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, groupMetadataHash)),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1),
                        mkTopicAssignment(barTopicId, 0)
                    )),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, mkAssignment(
                        mkTopicAssignment(fooTopicId, 2, 3),
                        mkTopicAssignment(barTopicId, 1)
                    )),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId3, mkAssignment(
                        mkTopicAssignment(fooTopicId, 4, 5),
                        mkTopicAssignment(barTopicId, 2)
                    ))
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds())),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember3))
            ),
            result.records()
        );
    }

    @Test
    public void testStaticMemberGetsBackAssignmentUponRejoin() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String member2RejoinId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setInstanceId(memberId1)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2),
                mkTopicAssignment(barTopicId, 0, 1)), 10))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setInstanceId(memberId2)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)), 10))
            .build();

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .build();

        // Consumer group with two static members.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage)),
                    barTopicName, computeTopicHash(barTopicName, new KRaftCoordinatorMetadataImage(metadataImage))
                ))))
            .build();

        // Member 2 leaves the consumer group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setInstanceId(memberId2)
                .setMemberEpoch(-2)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        // Member epoch of the response would be set to -2.
        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(-2),
            result.response()
        );

        // The departing static member will have it's epoch set to -2.
        ConsumerGroupMember member2UpdatedEpoch = new ConsumerGroupMember.Builder(member2)
            .setMemberEpoch(-2)
            .setPartitionsPendingRevocation(Map.of())
            .resetAssignedPartitionsEpochsToZero()
            .build();

        assertEquals(1, result.records().size());
        assertRecordEquals(result.records().get(0), GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member2UpdatedEpoch));

        // Member 2 rejoins the group with the same instance id.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> rejoinResult = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setMemberId(member2RejoinId)
                .setGroupId(groupId)
                .setInstanceId(memberId2)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(member2RejoinId)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(3, 4, 5)),
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(2))
                    ))),
            rejoinResult.response()
        );

        ConsumerGroupMember expectedCopiedMember = new ConsumerGroupMember.Builder(member2RejoinId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setInstanceId(memberId2)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5),
                mkTopicAssignment(barTopicId, 2)), 0))
            .build();

        ConsumerGroupMember expectedRejoinedMember = new ConsumerGroupMember.Builder(member2RejoinId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(0)
            .setInstanceId(memberId2)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5),
                mkTopicAssignment(barTopicId, 2)), 0))
            .build();

        List<CoordinatorRecord> expectedRecordsAfterRejoin = List.of(
            // The previous member is deleted.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2),

            // The previous member is replaced by the new one.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedCopiedMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, member2RejoinId, mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5),
                mkTopicAssignment(barTopicId, 2))),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedCopiedMember),

            // The new member is updated.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedRejoinedMember)
        );

        assertRecordsEquals(expectedRecordsAfterRejoin, rejoinResult.records());
        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId2);
        context.assertNoRebalanceTimeout(groupId, memberId2);
    }

    @Test
    public void testStaticMemberRejoinsWithNewSubscribedTopics() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String member2RejoinId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setInstanceId("instance-id-1")
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setInstanceId("instance-id-2")
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5)), 10))
            .build();

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .build();
        long fooTopicHash = computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage));
        long barTopicHash = computeTopicHash(barTopicName, new KRaftCoordinatorMetadataImage(metadataImage));

        // Consumer group with two static members.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash
                ))))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2)
            )),
            member2RejoinId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            ))
        )));

        // Member 2 leaves the consumer group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setInstanceId("instance-id-2")
                .setMemberEpoch(-2));

        // Member epoch of the response would be set to -2.
        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(-2),
            result.response()
        );

        // The departing static member will have it's epoch set to -2.
        ConsumerGroupMember member2UpdatedEpoch = new ConsumerGroupMember.Builder(member2)
            .setMemberEpoch(-2)
            .setPartitionsPendingRevocation(Map.of())
            .resetAssignedPartitionsEpochsToZero()
            .build();

        assertEquals(1, result.records().size());
        assertRecordEquals(result.records().get(0), GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member2UpdatedEpoch));

        // Member 2 rejoins the group with the same instance id.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> rejoinResult = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setMemberId(member2RejoinId)
                .setGroupId(groupId)
                .setInstanceId("instance-id-2")
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of("foo", "bar")) // bar is new.
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(member2RejoinId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(3, 4, 5)),
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(0, 1, 2))
                    ))),
            rejoinResult.response()
        );

        ConsumerGroupMember expectedCopiedMember = new ConsumerGroupMember.Builder(member2RejoinId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setInstanceId("instance-id-2")
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5)), 0))
            .build();

        // foo partitions retain epoch 0 (from reset), bar partitions get epoch 11 (newly assigned)
        ConsumerGroupMember expectedRejoinedMember = new ConsumerGroupMember.Builder(member2RejoinId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(0)
            .setInstanceId("instance-id-2")
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(mkAssignmentWithEpochs(
                mkTopicAssignmentWithEpochs(fooTopicId, 0, 3, 4, 5),
                mkTopicAssignmentWithEpochs(barTopicId, 11, 0, 1, 2)
            ))
            .build();

        List<CoordinatorRecord> expectedRecordsAfterRejoin = List.of(
            // The previous member is deleted.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2),

            // The new member is created as a copy of the previous one but
            // with its new member id and new epochs.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedCopiedMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, member2RejoinId, mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5))),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedCopiedMember),

            // As the new member as a different subscribed topic set, a rebalance is triggered.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedRejoinedMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, fooTopicHash,
                barTopicName, barTopicHash
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, member2RejoinId, mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            )),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedRejoinedMember)
        );

        assertRecordsEquals(expectedRecordsAfterRejoin, rejoinResult.records());
        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId2);
        context.assertNoRebalanceTimeout(groupId, memberId2);
    }

    @Test
    public void testNoGroupEpochBumpWhenStaticMemberTemporarilyLeaves() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setInstanceId(memberId1)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2),
                mkTopicAssignment(barTopicId, 0, 1)), 10))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setInstanceId(memberId2)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            // Use zar only here to ensure that metadata needs to be recomputed.
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5),
                mkTopicAssignment(barTopicId, 2)), 10))
            .build();

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .build();

        // Consumer group with two static members.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage)),
                    barTopicName, computeTopicHash(barTopicName, new KRaftCoordinatorMetadataImage(metadataImage))
                ))))
            .build();

        // Member 2 leaves the consumer group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setInstanceId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        // member epoch of the response would be set to -2
        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH),
            result.response()
        );

        ConsumerGroupMember member2UpdatedEpoch = new ConsumerGroupMember
            .Builder(member2)
            .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH)
            .setPartitionsPendingRevocation(Map.of())
            .resetAssignedPartitionsEpochsToZero()
            .build();

        assertEquals(1, result.records().size());
        assertRecordEquals(result.records().get(0), GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member2UpdatedEpoch));
    }

    @Test
    public void testLeavingStaticMemberBumpsGroupEpoch() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Uuid zarTopicId = Uuid.randomUuid();
        String zarTopicName = "zar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addTopic(zarTopicId, zarTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);
        long zarTopicHash = computeTopicHash(zarTopicName, metadataImage);

        // Consumer group with two static members.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2),
                        mkTopicAssignment(barTopicId, 0, 1)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId2)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    // Use zar only here to ensure that metadata needs to be recomputed.
                    .setSubscribedTopicNames(List.of("foo", "bar", "zar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5),
                        mkTopicAssignment(barTopicId, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash,
                    zarTopicName, zarTopicHash
                ))))
            .build();

        // Member 2 leaves the consumer group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setInstanceId(memberId2)
                .setMemberId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH),
            result.response()
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2),
            // Group metadata hash is recomputed because zar is no longer there.
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, fooTopicHash,
                barTopicName, barTopicHash
            )))
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testShouldThrownUnreleasedInstanceIdExceptionWhenNewMemberJoinsWithInUseInstanceId() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with one static member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName,
                    computeTopicHash(fooTopicName, metadataImage))
                )))
            .build();

        // Member 2 joins the consumer group with an in-use instance id.
        assertThrows(UnreleasedInstanceIdException.class, () -> context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setInstanceId(memberId1)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of())));
    }

    @Test
    public void testShouldThrownUnknownMemberIdExceptionWhenUnknownStaticMemberJoins() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        // Consumer group with one static member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName,
                    computeTopicHash(fooTopicName, metadataImage))
                )))
            .build();

        // Member 2 joins the consumer group with a non-zero epoch
        assertThrows(UnknownMemberIdException.class, () -> context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setInstanceId(memberId2)
                .setMemberEpoch(10)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of())));
    }

    @Test
    public void testShouldThrowFencedInstanceIdExceptionWhenStaticMemberWithDifferentMemberIdJoins() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        // Consumer group with one static member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName,
                    computeTopicHash(fooTopicName, metadataImage))
                )))
            .build();

        assertThrows(FencedInstanceIdException.class, () -> context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId("unknown-" + memberId1)
                .setInstanceId(memberId1)
                .setMemberEpoch(11)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of())));
    }

    @Test
    public void testConsumerGroupMemberEpochValidationForStaticMember() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .build();

        ConsumerGroupMember member = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setInstanceId(memberId)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 1, 2, 3)), 100))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, member));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 100, 0));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
            mkTopicAssignment(fooTopicId, 1, 2, 3)
        )));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 100, 12345L));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, member));

        // Member epoch is greater than the expected epoch.
        assertThrows(FencedMemberEpochException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setInstanceId(memberId)
                    .setMemberEpoch(200)
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))));

        // Member epoch is smaller than the expected epoch.
        assertThrows(FencedMemberEpochException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setInstanceId(memberId)
                    .setMemberEpoch(50)
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))));

        // Member joins with previous epoch but without providing partitions.
        assertThrows(FencedMemberEpochException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setInstanceId(memberId)
                    .setMemberEpoch(99)
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))));

        // Member joins with previous epoch and has a subset of the owned partitions. This
        // is accepted as the response with the bumped epoch may have been lost. In this
        // case, we provide back the correct epoch to the member.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setInstanceId(memberId)
                .setMemberEpoch(99)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of(new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(fooTopicId)
                    .setPartitions(List.of(1, 2)))));
        assertEquals(100, result.response().memberEpoch());
    }

    @Test
    public void testShouldThrowUnknownMemberIdExceptionWhenUnknownStaticMemberLeaves() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        // Consumer group with one static member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName,
                    computeTopicHash(fooTopicName, metadataImage))
                )))
            .build();

        assertThrows(UnknownMemberIdException.class, () -> context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setInstanceId("unknown-" + memberId1)
                .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of())));
    }

    @Test
    public void testShouldThrowFencedInstanceIdExceptionWhenStaticMemberWithDifferentMemberIdLeaves() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        // Consumer group with one static member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName,
                    computeTopicHash(fooTopicName, metadataImage))
                )))
            .build();

        assertThrows(FencedInstanceIdException.class, () -> context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId("unknown-" + memberId1)
                .setInstanceId(memberId1)
                .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of())));
    }

    @Test
    public void testConsumerGroupHeartbeatFullResponse() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        // Create a context with an empty consumer group.
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 2)
                .addRacks()
                .buildCoordinatorMetadataImage())
            .build();

        // Prepare new assignment for the group.
        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(mkTopicAssignment(fooTopicId, 0, 1))))
        ));

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result;

        // A full response should be sent back on joining.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1))))),
            result.response()
        );

        // Otherwise, a partial response should be sent back.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().memberEpoch()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000),
            result.response()
        );

        // A full response should be sent back when the member sends
        // a full request again with topic names set.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().memberEpoch())
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1))))),
            result.response()
        );

        // A full response should be sent back when the member sends
        // a full request again with regex set.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().memberEpoch())
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo.*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1))))),
            result.response()
        );
    }

    @Test
    public void testReconciliationProcess() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Create a context with one consumer group containing two members.
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2),
                        mkTopicAssignment(barTopicId, 0, 1)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5),
                        mkTopicAssignment(barTopicId, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        // Prepare new assignment for the group.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1),
                mkTopicAssignment(barTopicId, 0)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 2, 3),
                mkTopicAssignment(barTopicId, 2)
            )),
            memberId3, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 4, 5),
                mkTopicAssignment(barTopicId, 1)
            ))
        )));

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result;

        // Members in the group are in Stable state.
        assertEquals(MemberState.STABLE, context.consumerGroupMemberState(groupId, memberId1));
        assertEquals(MemberState.STABLE, context.consumerGroupMemberState(groupId, memberId2));
        assertEquals(ConsumerGroup.ConsumerGroupState.STABLE, context.consumerGroupState(groupId));

        // Member 3 joins the group. This triggers the computation of a new target assignment
        // for the group. Member 3 does not get any assigned partitions yet because they are
        // all owned by other members. However, it transitions to epoch 11 and the
        // Unreleased Partitions state.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId3)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()),
            result.response()
        );

        // We only check the last record as the subscription/target assignment updates are
        // already covered by other tests.
        assertRecordEquals(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberId3)
                .setState(MemberState.UNRELEASED_PARTITIONS)
                .setMemberEpoch(11)
                .setPreviousMemberEpoch(0)
                .build()),
            result.records().get(result.records().size() - 1)
        );

        assertEquals(MemberState.UNRELEASED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId3));
        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        // Member 1 heartbeats. It remains at epoch 10 but transitions to Unrevoked Partitions
        // state until it acknowledges the revocation of its partitions. The response contains the new
        // assignment without the partitions that must be revoked.
        result = context.consumerGroupHeartbeat(new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId1)
            .setMemberEpoch(10));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1)),
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(0))
                    ))),
            result.response()
        );

        assertRecordsEquals(List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberId1)
                .setState(MemberState.UNREVOKED_PARTITIONS)
                .setMemberEpoch(10)
                .setPreviousMemberEpoch(10)
                .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1),
                    mkTopicAssignment(barTopicId, 0)), 10))
                .setPartitionsPendingRevocation(toAssignmentWithEpochs(mkAssignment(
                    mkTopicAssignment(fooTopicId, 2),
                    mkTopicAssignment(barTopicId, 1)), 10))
                .build())),
            result.records()
        );

        assertEquals(MemberState.UNREVOKED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId1));
        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        // Member 2 heartbeats. It remains at epoch 10 but transitions to Unrevoked Partitions
        // state until it acknowledges the revocation of its partitions. The response contains the new
        // assignment without the partitions that must be revoked.
        result = context.consumerGroupHeartbeat(new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId2)
            .setMemberEpoch(10));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(3)),
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(2))
                    ))),
            result.response()
        );

        assertRecordsEquals(List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberId2)
                .setState(MemberState.UNREVOKED_PARTITIONS)
                .setMemberEpoch(10)
                .setPreviousMemberEpoch(10)
                .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                    mkTopicAssignment(fooTopicId, 3),
                    mkTopicAssignment(barTopicId, 2)), 10))
                .setPartitionsPendingRevocation(toAssignmentWithEpochs(mkAssignment(
                    mkTopicAssignment(fooTopicId, 4, 5)), 10))
                .build())),
            result.records()
        );

        assertEquals(MemberState.UNREVOKED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId2));
        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        // Member 3 heartbeats. The response does not contain any assignment
        // because the member is still waiting on other members to revoke partitions.
        result = context.consumerGroupHeartbeat(new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId3)
            .setMemberEpoch(11));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000),
            result.response()
        );

        assertRecordsEquals(List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberId3)
                .setState(MemberState.UNRELEASED_PARTITIONS)
                .setMemberEpoch(11)
                .setPreviousMemberEpoch(11)
                .build())),
            result.records()
        );

        assertEquals(MemberState.UNRELEASED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId3));
        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        // Member 1 acknowledges the revocation of the partitions. It does so by providing the
        // partitions that it still owns in the request. This allows him to transition to epoch 11
        // and to the Stable state.
        result = context.consumerGroupHeartbeat(new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId1)
            .setMemberEpoch(10)
            .setTopicPartitions(List.of(
                new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(fooTopicId)
                    .setPartitions(List.of(0, 1)),
                new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(barTopicId)
                    .setPartitions(List.of(0))
            )));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000),
            result.response()
        );

        assertRecordsEquals(List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberId1)
                .setState(MemberState.STABLE)
                .setMemberEpoch(11)
                .setPreviousMemberEpoch(10)
                .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1),
                    mkTopicAssignment(barTopicId, 0)), 10))
                .build())),
            result.records()
        );

        assertEquals(MemberState.STABLE, context.consumerGroupMemberState(groupId, memberId1));
        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        // Member 2 heartbeats but without acknowledging the revocation yet. This is basically a no-op.
        result = context.consumerGroupHeartbeat(new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId2)
            .setMemberEpoch(10));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000),
            result.response()
        );

        assertEquals(List.of(), result.records());
        assertEquals(MemberState.UNREVOKED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId2));
        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        // Member 3 heartbeats. It receives the partitions revoked by member 1 but remains
        // in Unreleased Partitions state because it still waits on other partitions.
        result = context.consumerGroupHeartbeat(new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId3)
            .setMemberEpoch(11));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(1))))),
            result.response()
        );

        assertRecordsEquals(List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberId3)
                .setState(MemberState.UNRELEASED_PARTITIONS)
                .setMemberEpoch(11)
                .setPreviousMemberEpoch(11)
                .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                    mkTopicAssignment(barTopicId, 1)), 11))
                .build())),
            result.records()
        );

        assertEquals(MemberState.UNRELEASED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId3));
        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        // Member 3 heartbeats. Member 2 has not acknowledged the revocation of its partition so
        // member keeps its current assignment.
        result = context.consumerGroupHeartbeat(new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId3)
            .setMemberEpoch(11));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000),
            result.response()
        );

        assertEquals(List.of(), result.records());
        assertEquals(MemberState.UNRELEASED_PARTITIONS, context.consumerGroupMemberState(groupId, memberId3));
        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        // Member 2 acknowledges the revocation of the partitions. It does so by providing the
        // partitions that it still owns in the request. This allows him to transition to epoch 11
        // and to the Stable state.
        result = context.consumerGroupHeartbeat(new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId2)
            .setMemberEpoch(10)
            .setTopicPartitions(List.of(
                new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(fooTopicId)
                    .setPartitions(List.of(3)),
                new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(barTopicId)
                    .setPartitions(List.of(2))
            )));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(2, 3)),
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(2))
                    ))),
            result.response()
        );

        // member2: partition 3 (fooTopicId) and 2 (barTopicId) were retained from epoch 10,
        // partition 2 (fooTopicId) is newly assigned at epoch 11
        assertRecordsEquals(List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberId2)
                .setState(MemberState.STABLE)
                .setMemberEpoch(11)
                .setPreviousMemberEpoch(10)
                .setAssignedPartitions(mkAssignmentWithEpochs(
                    mkTopicAssignmentWithEpochs(fooTopicId, 11, 2),
                    mkTopicAssignmentWithEpochs(fooTopicId, 10, 3),
                    mkTopicAssignmentWithEpochs(barTopicId, 10, 2)
                ))
                .build())),
            result.records()
        );

        assertEquals(MemberState.STABLE, context.consumerGroupMemberState(groupId, memberId2));
        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        // Member 3 heartbeats to acknowledge its current assignment. It receives all its partitions and
        // transitions to Stable state.
        result = context.consumerGroupHeartbeat(new ConsumerGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId3)
            .setMemberEpoch(11)
            .setTopicPartitions(List.of(
                new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(barTopicId)
                    .setPartitions(List.of(1)))));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(4, 5)),
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(1))))),
            result.response()
        );

        // member3: all partitions are newly assigned at epoch 11
        assertRecordsEquals(List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberId3)
                .setState(MemberState.STABLE)
                .setMemberEpoch(11)
                .setPreviousMemberEpoch(11)
                .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                    mkTopicAssignment(fooTopicId, 4, 5),
                    mkTopicAssignment(barTopicId, 1)), 11))
                .build())),
            result.records()
        );

        assertEquals(MemberState.STABLE, context.consumerGroupMemberState(groupId, memberId3));
        assertEquals(ConsumerGroup.ConsumerGroupState.STABLE, context.consumerGroupState(groupId));
    }

    @Test
    public void testNewMemberIsRejectedWithMaximumMembersIsReached() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage();

        // Create a context with one consumer group containing two members.
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MAX_SIZE_CONFIG, 2)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2),
                        mkTopicAssignment(barTopicId, 0, 1)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5),
                        mkTopicAssignment(barTopicId, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        assertThrows(GroupMaxSizeReachedException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId3)
                    .setMemberEpoch(0)
                    .setServerAssignor("range")
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setTopicPartitions(List.of())));
    }

    @Test
    public void testConsumerGroupStates() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10))
            .build();

        assertEquals(ConsumerGroup.ConsumerGroupState.EMPTY, context.consumerGroupState(groupId));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setSubscribedTopicNames(List.of(fooTopicName))
            .build()));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, metadataImage)
        ))));

        assertEquals(ConsumerGroup.ConsumerGroupState.ASSIGNING, context.consumerGroupState(groupId));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
            mkTopicAssignment(fooTopicId, 1, 2, 3))));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, 12345L));

        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.UNREVOKED_PARTITIONS)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 1, 2, 3)), 10))
            .build()));

        assertEquals(ConsumerGroup.ConsumerGroupState.RECONCILING, context.consumerGroupState(groupId));

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 1, 2, 3)), 10))
            .build()));

        assertEquals(ConsumerGroup.ConsumerGroupState.STABLE, context.consumerGroupState(groupId));
    }

    @Test
    public void testPartitionAssignorExceptionOnRegularHeartbeat() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        ConsumerGroupPartitionAssignor assignor = mock(ConsumerGroupPartitionAssignor.class);
        when(assignor.name()).thenReturn("range");
        when(assignor.assign(any(), any())).thenThrow(new PartitionAssignorException("Assignment failed."));

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addTopic(barTopicId, barTopicName, 3)
                .addRacks()
                .buildCoordinatorMetadataImage())
            .build();

        // Member 1 joins the consumer group. The request fails because the
        // target assignment computation failed.
        assertThrows(UnknownServerException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId1)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignor("range")
                    .setTopicPartitions(List.of())));
    }

    @Test
    public void testSubscriptionMetadataRefreshedAfterGroupIsLoaded() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Create a context with one consumer group containing one member.
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, new MetadataImageBuilder()
                        // foo only has 3 partitions stored in the metadata but foo has
                        // 6 partitions the metadata image.
                        .addTopic(fooTopicId, fooTopicName, 3)
                        .addRacks()
                        .buildCoordinatorMetadataImage())
                ))))
            .build();

        // The metadata refresh flag should be true.
        ConsumerGroup consumerGroup = context.groupMetadataManager
            .consumerGroup(groupId);
        assertTrue(consumerGroup.hasMetadataExpired(context.time.milliseconds()));

        // Prepare the assignment result.
        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        // Heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10));

        // The member gets partitions 3, 4 and 5 assigned.
        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                    ))),
            result.response()
        );

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(mkAssignmentWithEpochs(
                mkTopicAssignmentWithEpochs(fooTopicId, 10, 0, 1, 2),
                mkTopicAssignmentWithEpochs(fooTopicId, 11, 3, 4, 5)))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());

        // Check next refresh time.
        assertFalse(consumerGroup.hasMetadataExpired(context.time.milliseconds()));
        assertEquals(context.time.milliseconds() + Integer.MAX_VALUE, consumerGroup.metadataRefreshDeadline().deadlineMs);
        assertEquals(11, consumerGroup.metadataRefreshDeadline().epoch);
    }

    @Test
    public void testSubscriptionMetadataRefreshedAgainAfterWriteFailure() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Create a context with one consumer group containing one member.
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, new MetadataImageBuilder()
                        // foo only has 3 partitions stored in the metadata but foo has
                        // 6 partitions the metadata image.
                        .addTopic(fooTopicId, fooTopicName, 3)
                        .addRacks()
                        .buildCoordinatorMetadataImage())
                ))))
            .build();

        // The metadata refresh flag should be true.
        ConsumerGroup consumerGroup = context.groupMetadataManager
            .consumerGroup(groupId);
        assertTrue(consumerGroup.hasMetadataExpired(context.time.milliseconds()));

        // Prepare the assignment result.
        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        // Heartbeat.
        context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10));

        // The metadata refresh flag is set to a future time.
        assertFalse(consumerGroup.hasMetadataExpired(context.time.milliseconds()));
        assertEquals(context.time.milliseconds() + Integer.MAX_VALUE, consumerGroup.metadataRefreshDeadline().deadlineMs);
        assertEquals(11, consumerGroup.metadataRefreshDeadline().epoch);

        // Rollback the uncommitted changes. This does not rollback the metadata flag
        // because it is not using a timeline data structure.
        context.rollback();

        // However, the next heartbeat should detect the divergence based on the epoch and trigger
        // a metadata refresh.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10));


        // The member gets partitions 3, 4 and 5 assigned.
        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                    ))),
            result.response()
        );

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setAssignedPartitions(mkAssignmentWithEpochs(
                    mkTopicAssignmentWithEpochs(fooTopicId, 10, 0, 1, 2),
                    mkTopicAssignmentWithEpochs(fooTopicId, 11, 3, 4, 5)))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());

        // Check next refresh time.
        assertFalse(consumerGroup.hasMetadataExpired(context.time.milliseconds()));
        assertEquals(context.time.milliseconds() + Integer.MAX_VALUE, consumerGroup.metadataRefreshDeadline().deadlineMs);
        assertEquals(11, consumerGroup.metadataRefreshDeadline().epoch);
    }

    @Test
    public void testGroupIdsByTopics() {
        String groupId1 = "group1";
        String groupId2 = "group2";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .build();

        assertEquals(Set.of(), context.groupMetadataManager.groupsSubscribedToTopic("foo"));
        assertEquals(Set.of(), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
        assertEquals(Set.of(), context.groupMetadataManager.groupsSubscribedToTopic("zar"));

        // M1 in group 1 subscribes to foo and bar.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId1,
            new ConsumerGroupMember.Builder("group1-m1")
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .build()));

        assertEquals(Set.of(groupId1), context.groupMetadataManager.groupsSubscribedToTopic("foo"));
        assertEquals(Set.of(groupId1), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
        assertEquals(Set.of(), context.groupMetadataManager.groupsSubscribedToTopic("zar"));

        // M1 in group 2 subscribes to foo, bar and zar.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId2,
            new ConsumerGroupMember.Builder("group2-m1")
                .setSubscribedTopicNames(List.of("foo", "bar", "zar"))
                .build()));

        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("foo"));
        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
        assertEquals(Set.of(groupId2), context.groupMetadataManager.groupsSubscribedToTopic("zar"));

        // M2 in group 1 subscribes to bar and zar.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId1,
            new ConsumerGroupMember.Builder("group1-m2")
                .setSubscribedTopicNames(List.of("bar", "zar"))
                .build()));

        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("foo"));
        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("zar"));

        // M2 in group 2 subscribes to foo and bar.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId2,
            new ConsumerGroupMember.Builder("group2-m2")
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .build()));

        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("foo"));
        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("zar"));

        // M1 in group 1 is removed.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId1, "group1-m1"));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId1, "group1-m1"));

        assertEquals(Set.of(groupId2), context.groupMetadataManager.groupsSubscribedToTopic("foo"));
        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("zar"));

        // M1 in group 2 subscribes to nothing.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId2,
            new ConsumerGroupMember.Builder("group2-m1")
                .setSubscribedTopicNames(List.of())
                .build()));

        assertEquals(Set.of(groupId2), context.groupMetadataManager.groupsSubscribedToTopic("foo"));
        assertEquals(Set.of(groupId1, groupId2), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
        assertEquals(Set.of(groupId1), context.groupMetadataManager.groupsSubscribedToTopic("zar"));

        // M2 in group 2 subscribes to foo.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId2,
            new ConsumerGroupMember.Builder("group2-m2")
                .setSubscribedTopicNames(List.of("foo"))
                .build()));

        assertEquals(Set.of(groupId2), context.groupMetadataManager.groupsSubscribedToTopic("foo"));
        assertEquals(Set.of(groupId1), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
        assertEquals(Set.of(groupId1), context.groupMetadataManager.groupsSubscribedToTopic("zar"));

        // M2 in group 2 subscribes to nothing.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId2,
            new ConsumerGroupMember.Builder("group2-m2")
                .setSubscribedTopicNames(List.of())
                .build()));

        assertEquals(Set.of(), context.groupMetadataManager.groupsSubscribedToTopic("foo"));
        assertEquals(Set.of(groupId1), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
        assertEquals(Set.of(groupId1), context.groupMetadataManager.groupsSubscribedToTopic("zar"));

        // M2 in group 1 subscribes to nothing.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId1,
            new ConsumerGroupMember.Builder("group1-m2")
                .setSubscribedTopicNames(List.of())
                .build()));

        assertEquals(Set.of(), context.groupMetadataManager.groupsSubscribedToTopic("foo"));
        assertEquals(Set.of(), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
        assertEquals(Set.of(), context.groupMetadataManager.groupsSubscribedToTopic("zar"));
    }

    @Test
    public void testOnMetadataUpdateWithEmptyDelta() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new MockPartitionAssignor("range")))
            .build();

        MetadataDelta delta = new MetadataDelta.Builder()
            .setImage(MetadataImage.EMPTY)
            .build();
        MetadataImage image = delta.apply(MetadataProvenance.EMPTY);

        context.groupMetadataManager.onMetadataUpdate(new KRaftCoordinatorMetadataDelta(delta), new KRaftCoordinatorMetadataImage(image));
        assertEquals(new KRaftCoordinatorMetadataImage(image), context.groupMetadataManager.image());
    }

    @Test
    public void testOnMetadataUpdate() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // M1 in group 1 subscribes to a and b.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord("group1",
            new ConsumerGroupMember.Builder("group1-m1")
                .setSubscribedTopicNames(List.of("a", "b"))
                .build()));

        // M1 in group 2 subscribes to b and c.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord("group2",
            new ConsumerGroupMember.Builder("group2-m1")
                .setSubscribedTopicNames(List.of("b", "c"))
                .build()));

        // M1 in group 3 subscribes to d.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord("group3",
            new ConsumerGroupMember.Builder("group3-m1")
                .setSubscribedTopicNames(List.of("d"))
                .build()));

        // M1 in group 4 subscribes to e.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord("group4",
            new ConsumerGroupMember.Builder("group4-m1")
                .setSubscribedTopicNames(List.of("e"))
                .build()));

        // M1 in group 5 subscribes to f.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord("group5",
            new ConsumerGroupMember.Builder("group5-m1")
                .setSubscribedTopicNames(List.of("f"))
                .build()));

        // Ensures that all refresh flags are set to the future.
        List.of("group1", "group2", "group3", "group4", "group5").forEach(groupId -> {
            ConsumerGroup group = context.groupMetadataManager.consumerGroup(groupId);
            group.setMetadataRefreshDeadline(context.time.milliseconds() + 5000L, 0);
            assertFalse(group.hasMetadataExpired(context.time.milliseconds()));
        });

        // Update the metadata image.
        Uuid topicA = Uuid.randomUuid();
        Uuid topicB = Uuid.randomUuid();
        Uuid topicC = Uuid.randomUuid();
        Uuid topicD = Uuid.randomUuid();
        Uuid topicE = Uuid.randomUuid();

        // Create a first base image with topic a, b, c and d.
        MetadataDelta delta = new MetadataDelta.Builder()
            .setImage(MetadataImage.EMPTY)
            .build();
        delta.replay(new TopicRecord().setTopicId(topicA).setName("a"));
        delta.replay(new PartitionRecord().setTopicId(topicA).setPartitionId(0));
        delta.replay(new TopicRecord().setTopicId(topicB).setName("b"));
        delta.replay(new PartitionRecord().setTopicId(topicB).setPartitionId(0));
        delta.replay(new TopicRecord().setTopicId(topicC).setName("c"));
        delta.replay(new PartitionRecord().setTopicId(topicC).setPartitionId(0));
        delta.replay(new TopicRecord().setTopicId(topicD).setName("d"));
        delta.replay(new PartitionRecord().setTopicId(topicD).setPartitionId(0));
        MetadataImage image = delta.apply(MetadataProvenance.EMPTY);

        // Create a delta which updates topic B, deletes topic D and creates topic E.
        delta = new MetadataDelta.Builder()
            .setImage(image)
            .build();
        delta.replay(new PartitionRecord().setTopicId(topicB).setPartitionId(2));
        delta.replay(new RemoveTopicRecord().setTopicId(topicD));
        delta.replay(new TopicRecord().setTopicId(topicE).setName("e"));
        delta.replay(new PartitionRecord().setTopicId(topicE).setPartitionId(1));
        image = delta.apply(MetadataProvenance.EMPTY);

        // Update metadata image with the delta.
        context.groupMetadataManager.onMetadataUpdate(new KRaftCoordinatorMetadataDelta(delta), new KRaftCoordinatorMetadataImage(image));

        // Verify the groups.
        List.of("group1", "group2", "group3", "group4").forEach(groupId -> {
            ConsumerGroup group = context.groupMetadataManager.consumerGroup(groupId);
            assertTrue(group.hasMetadataExpired(context.time.milliseconds()), groupId);
        });

        List.of("group5").forEach(groupId -> {
            ConsumerGroup group = context.groupMetadataManager.consumerGroup(groupId);
            assertFalse(group.hasMetadataExpired(context.time.milliseconds()));
        });

        // Verify image.
        assertEquals(new KRaftCoordinatorMetadataImage(image), context.groupMetadataManager.image());
    }

    @Test
    public void testSessionTimeoutLifecycle() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addRacks()
                .build()))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result =
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(90000)
                    .setSubscribedTopicNames(List.of("foo"))
                    .setTopicPartitions(List.of()));
        assertEquals(2, result.response().memberEpoch());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().heartbeatIntervalMs())
        );

        // Session timer is rescheduled on second heartbeat.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().memberEpoch()));
        assertEquals(2, result.response().memberEpoch());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().heartbeatIntervalMs())
        );

        // Session timer is cancelled on leave.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH));
        assertEquals(LEAVE_GROUP_MEMBER_EPOCH, result.response().memberEpoch());

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
        context.assertNoRebalanceTimeout(groupId, memberId);
    }

    @Test
    public void testSessionTimeoutExpiration() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addRacks()
                .build()))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result =
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(90000)
                    .setSubscribedTopicNames(List.of("foo"))
                    .setTopicPartitions(List.of()));
        assertEquals(2, result.response().memberEpoch());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Advance time past the session timeout.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(45000 + 1);

        // Verify the expired timeout.
        assertEquals(
            List.of(new ExpiredTimeout<>(
                groupSessionTimeoutKey(groupId, memberId),
                new CoordinatorResult<>(
                    List.of(
                        GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
                        GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId),
                        GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId),
                        GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 3, 0)
                    )
                )
            )),
            timeouts
        );

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
        context.assertNoRebalanceTimeout(groupId, memberId);
    }

    @Test
    public void testOnLoadedSessionTimeoutExpiration() {
        String groupId = "group";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        String memberId = "foo-1";

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .build();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder("foo-1")
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(9)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage)))
                )))
            .build();

        // Let's assume that all the records have been replayed and now
        // onLoaded is called to signal it.
        context.groupMetadataManager.onLoaded();

        // All members should have a session timeout in place.
        assertNotNull(context.timer.timeout(groupSessionTimeoutKey(groupId, memberId)));

        // Advance time past the session timeout.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(45000 + 1);

        // Verify the expired timeout.
        assertEquals(
            List.of(
                new ExpiredTimeout<>(
                    groupSessionTimeoutKey(groupId, memberId),
                    new CoordinatorResult<>(
                        List.of(
                            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
                            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId),
                            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId),
                            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, 0)
                        )
                    )
                )
            ),
            timeouts
        );

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
    }

    @Test
    public void testSessionTimeoutExpirationForShareMember() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(new KRaftCoordinatorMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addRacks()
                .build()))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result =
            context.shareGroupHeartbeat(
                new ShareGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setSubscribedTopicNames(List.of("foo")));
        assertEquals(2, result.response().getKey().memberEpoch());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Advance time past the session timeout.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(45000 + 1);

        // Verify the expired timeout.
        assertEquals(
            List.of(new ExpiredTimeout<>(
                groupSessionTimeoutKey(groupId, memberId),
                new CoordinatorResult<>(
                    List.of(
                        GroupCoordinatorRecordHelpers.newShareGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
                        GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentTombstoneRecord(groupId, memberId),
                        GroupCoordinatorRecordHelpers.newShareGroupMemberSubscriptionTombstoneRecord(groupId, memberId),
                        GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 3, 0)
                    )
                )
            )),
            timeouts
        );

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
    }

    @Test
    public void testOnLoadedSessionTimeoutExpirationForShareMember() {
        String groupId = "group";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        String memberId = "foo-1";

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .build();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withShareGroup(new ShareGroupBuilder(groupId, 10)
                .withMember(new ShareGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(9)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .setAssignedPartitions(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage))
                ))))
            .build();

        // Let's assume that all the records have been replayed and now
        // onLoaded is called to signal it.
        context.groupMetadataManager.onLoaded();

        // All members should have a session timeout in place.
        assertNotNull(context.timer.timeout(groupSessionTimeoutKey(groupId, memberId)));

        // Advance time past the session timeout.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(45000 + 1);

        // Verify the expired timeout.
        assertEquals(
            List.of(
                new ExpiredTimeout<>(
                    groupSessionTimeoutKey(groupId, memberId),
                    new CoordinatorResult<>(
                        List.of(
                            GroupCoordinatorRecordHelpers.newShareGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
                            GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentTombstoneRecord(groupId, memberId),
                            GroupCoordinatorRecordHelpers.newShareGroupMemberSubscriptionTombstoneRecord(groupId, memberId),
                            GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 11, 0)
                        )
                    )
                )
            ),
            timeouts
        );

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
    }

    @Test
    public void testSessionTimeoutExpirationStaticMember() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addRacks()
                .build()))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result =
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setInstanceId(memberId)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(90000)
                    .setSubscribedTopicNames(List.of("foo"))
                    .setTopicPartitions(List.of()));
        assertEquals(2, result.response().memberEpoch());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Static member sends a temporary leave group request
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setInstanceId(memberId)
                .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH)
                .setRebalanceTimeoutMs(90000)
                .setSubscribedTopicNames(List.of("foo"))
                .setTopicPartitions(List.of()));

        assertEquals(-2, result.response().memberEpoch());

        // Verify that there is still a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Advance time past the session timeout. No static member joined back as a replacement
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(45000 + 1);

        // Verify the expired timeout.
        assertEquals(
            List.of(new ExpiredTimeout<>(
                groupSessionTimeoutKey(groupId, memberId),
                new CoordinatorResult<>(
                    List.of(
                        GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
                        GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId),
                        GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId),
                        GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 3, 0)
                    )
                )
            )),
            timeouts
        );

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
        context.assertNoRebalanceTimeout(groupId, memberId);
    }

    @Test
    public void testRebalanceTimeoutLifecycle() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 3)
                .addRacks()
                .build()))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(memberId1, new MemberAssignmentImpl(mkAssignment(
            mkTopicAssignment(fooTopicId, 0, 1, 2)
        )))));

        // Member 1 joins the group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result =
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId1)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(180000)
                    .setSubscribedTopicNames(List.of("foo"))
                    .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2))))),
            result.response()
        );

        assertEquals(
            List.of(),
            context.sleep(result.response().heartbeatIntervalMs())
        );

        // Prepare next assignment.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 2)
            ))
        )));

        // Member 2 joins the group.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(90000)
                .setSubscribedTopicNames(List.of("foo"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(3)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()),
            result.response()
        );

        assertEquals(
            List.of(),
            context.sleep(result.response().heartbeatIntervalMs())
        );

        // Member 1 heartbeats and transitions to unrevoked partitions. The rebalance timeout
        // is scheduled.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setRebalanceTimeoutMs(12000)
                .setSubscribedTopicNames(List.of("foo")));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1))))),
            result.response()
        );

        // Verify that there is a revocation timeout. Keep a reference
        // to the timeout for later.
        ScheduledTimeout<CoordinatorRecord> scheduledTimeout =
            context.assertRebalanceTimeout(groupId, memberId1, 12000);

        assertEquals(
            List.of(),
            context.sleep(result.response().heartbeatIntervalMs())
        );

        // Member 1 acks the revocation. The revocation timeout is cancelled.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setTopicPartitions(List.of(new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                    .setTopicId(fooTopicId)
                    .setPartitions(List.of(0, 1)))));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(3)
                .setHeartbeatIntervalMs(5000),
            result.response()
        );

        // Verify that there is not revocation timeout.
        context.assertNoRebalanceTimeout(groupId, memberId1);

        // Execute the scheduled revocation timeout captured earlier to simulate a
        // stale timeout. This should be a no-op.
        assertEquals(List.of(), scheduledTimeout.operation().generateRecords().records());
    }

    @Test
    public void testRebalanceTimeoutExpiration() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 3)
            .addRacks()
            .build();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId1, new MemberAssignmentImpl(mkAssignment(mkTopicAssignment(fooTopicId, 0, 1, 2))))
        ));

        // Member 1 joins the group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result =
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId1)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(10000) // Use timeout smaller than session timeout.
                    .setSubscribedTopicNames(List.of("foo"))
                    .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2))))),
            result.response()
        );

        assertEquals(
            List.of(),
            context.sleep(result.response().heartbeatIntervalMs())
        );

        // Prepare next assignment.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 2)
            ))
        )));

        // Member 2 joins the group.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(10000)
                .setSubscribedTopicNames(List.of("foo"))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(3)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()),
            result.response()
        );

        assertEquals(
            List.of(),
            context.sleep(result.response().heartbeatIntervalMs())
        );

        // Member 1 heartbeats and transitions to revoking. The revocation timeout
        // is scheduled.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(2));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1))))),
            result.response()
        );

        // Advance time past the revocation timeout.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(10000 + 1);

        // Verify the expired timeout.
        assertEquals(
            List.of(new ExpiredTimeout<>(
                groupRebalanceTimeoutKey(groupId, memberId1),
                new CoordinatorResult<>(
                    List.of(
                        GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
                        GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
                        GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
                        GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 4, computeGroupHash(Map.of(
                            fooTopicName, computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage))
                        )))
                    )
                )
            )),
            timeouts
        );

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId1);
        context.assertNoRebalanceTimeout(groupId, memberId1);
    }

    @Test
    public void testOnLoaded() {
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .build();
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(new KRaftCoordinatorMetadataImage(metadataImage))
            .withConsumerGroup(new ConsumerGroupBuilder("foo", 10)
                .withMember(new ConsumerGroupMember.Builder("foo-1")
                    .setState(MemberState.UNREVOKED_PARTITIONS)
                    .setMemberEpoch(9)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .setPartitionsPendingRevocation(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5)), 9))
                    .build())
                .withMember(new ConsumerGroupMember.Builder("foo-2")
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .setServerAssignorName("range")
                    .build())
                .withAssignment("foo-1", mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(metadataImage))
                ))))
            .build();

        // Let's assume that all the records have been replayed and now
        // onLoaded is called to signal it.
        context.groupMetadataManager.onLoaded();

        // All members should have a session timeout in place.
        assertNotNull(context.timer.timeout(groupSessionTimeoutKey("foo", "foo-1")));
        assertNotNull(context.timer.timeout(groupSessionTimeoutKey("foo", "foo-2")));

        // foo-1 should also have a revocation timeout in place.
        assertNotNull(context.timer.timeout(groupRebalanceTimeoutKey("foo", "foo-1")));
    }

    @Test
    public void testOnLoadedWithStreamsGroup() {
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(new KRaftCoordinatorMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addTopic(barTopicId, barTopicName, 3)
                .build()))
            .withStreamsGroup(new StreamsGroupBuilder("foo", 10)
                .withMember(new StreamsGroupMember.Builder("foo-1")
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.UNREVOKED_TASKS)
                    .setMemberEpoch(9)
                    .setPreviousMemberEpoch(9)
                    .setProcessId("process-id")
                    .setRackId(null)
                    .setInstanceId(null)
                    .setRebalanceTimeoutMs(100)
                    .setClientTags(new HashMap<>())
                    .setTopologyEpoch(1)
                    .setUserEndpoint(new Endpoint().setHost("localhost").setPort(1500))
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 9,
                        TaskAssignmentTestUtil.mkTasks(fooTopicName, 0, 1, 2)))
                    .setTasksPendingRevocation(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 9,
                        TaskAssignmentTestUtil.mkTasks(fooTopicName, 3, 4, 5)))
                    .build())
                .withMember(new StreamsGroupMember.Builder("foo-2")
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setProcessId("process-id")
                    .setRackId(null)
                    .setInstanceId(null)
                    .setAssignedTasks(TasksTupleWithEpochs.EMPTY)
                    .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
                    .setRebalanceTimeoutMs(100)
                    .setClientTags(new HashMap<>())
                    .setTopologyEpoch(1)
                    .setUserEndpoint(new Endpoint().setHost("localhost").setPort(1500))
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .build())
                .withTargetAssignment("foo-1", TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(fooTopicName, 3, 4, 5)))
                .withTargetAssignmentEpoch(10))
            .build();

        // Let's assume that all the records have been replayed and now
        // onLoaded is called to signal it.
        context.groupMetadataManager.onLoaded();

        // All members should have a session timeout in place.
        assertNotNull(context.timer.timeout(groupSessionTimeoutKey("foo", "foo-1")));
        assertNotNull(context.timer.timeout(groupSessionTimeoutKey("foo", "foo-2")));

        // foo-1 should also have a revocation timeout in place.
        assertNotNull(context.timer.timeout(groupRebalanceTimeoutKey("foo", "foo-1")));
    }

    @Test
    public void testOnLoadedWithShareGroup() {
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(new KRaftCoordinatorMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addTopic(barTopicId, barTopicName, 3)
                .build()))
            .withShareGroup(new ShareGroupBuilder("foo", 10)
                .withMember(new ShareGroupMember.Builder("foo-1")
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(9)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .setAssignedPartitions(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)))
                    .build())
                .withMember(new ShareGroupMember.Builder("foo-2")
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .build())
                .withAssignment("foo-1", mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withAssignmentEpoch(10))
            .build();

        // Let's assume that all the records have been replayed and now
        // onLoaded is called to signal it.
        context.groupMetadataManager.onLoaded();

        // All members should have a session timeout in place.
        assertNotNull(context.timer.timeout(groupSessionTimeoutKey("foo", "foo-1")));
        assertNotNull(context.timer.timeout(groupSessionTimeoutKey("foo", "foo-2")));
    }

    @Test
    public void testUpdateGroupSizeCounter() {
        List<String> groupIds = new ArrayList<>();
        IntStream.range(0, 8).forEach(i -> groupIds.add("group-" + i));
        List<String> consumerMemberIds = List.of("consumer-member-id-0", "consumer-member-id-1", "consumer-member-id-2");

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupIds.get(0), 10)) // Empty group
            .withConsumerGroup(new ConsumerGroupBuilder(groupIds.get(1), 10) // Stable group
                .withAssignmentEpoch(10)
                .withMember(new ConsumerGroupMember.Builder(consumerMemberIds.get(0))
                    .setMemberEpoch(10)
                    .build()))
            .withConsumerGroup(new ConsumerGroupBuilder(groupIds.get(2), 10) // Assigning group
                .withAssignmentEpoch(9)
                .withMember(new ConsumerGroupMember.Builder(consumerMemberIds.get(1))
                    .setMemberEpoch(9)
                    .build()))
            .withConsumerGroup(new ConsumerGroupBuilder(groupIds.get(3), 10) // Reconciling group
                .withAssignmentEpoch(10)
                .withMember(new ConsumerGroupMember.Builder(consumerMemberIds.get(2))
                    .setMemberEpoch(9)
                    .build()))
            .build();

        ClassicGroup group4 = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupIds.get(4), true);
        ClassicGroup group5 = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupIds.get(5), true);
        ClassicGroup group6 = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupIds.get(6), true);
        ClassicGroup group7 = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupIds.get(7), true);

        context.groupMetadataManager.updateGroupSizeCounter();
        verify(context.metrics, times(1)).setClassicGroupGauges(eq(Utils.mkMap(
            Utils.mkEntry(ClassicGroupState.EMPTY, 4L)
        )));
        verify(context.metrics, times(1)).setConsumerGroupGauges(eq(Utils.mkMap(
            Utils.mkEntry(ConsumerGroup.ConsumerGroupState.EMPTY, 1L),
            Utils.mkEntry(ConsumerGroup.ConsumerGroupState.ASSIGNING, 1L),
            Utils.mkEntry(ConsumerGroup.ConsumerGroupState.RECONCILING, 1L),
            Utils.mkEntry(ConsumerGroup.ConsumerGroupState.STABLE, 1L)
        )));

        group4.transitionTo(PREPARING_REBALANCE);
        group5.transitionTo(PREPARING_REBALANCE);
        group5.transitionTo(COMPLETING_REBALANCE);
        group6.transitionTo(PREPARING_REBALANCE);
        group6.transitionTo(COMPLETING_REBALANCE);
        group6.transitionTo(STABLE);
        group7.transitionTo(DEAD);

        context.groupMetadataManager.getOrMaybeCreateConsumerGroup(groupIds.get(1), false, List.of())
            .removeMember(consumerMemberIds.get(0));
        context.groupMetadataManager.getOrMaybeCreateConsumerGroup(groupIds.get(3), false, List.of())
            .updateMember(new ConsumerGroupMember.Builder(consumerMemberIds.get(2)).setMemberEpoch(10).build());

        context.groupMetadataManager.updateGroupSizeCounter();
        verify(context.metrics, times(1)).setClassicGroupGauges(eq(Utils.mkMap(
            Utils.mkEntry(ClassicGroupState.PREPARING_REBALANCE, 1L),
            Utils.mkEntry(ClassicGroupState.COMPLETING_REBALANCE, 1L),
            Utils.mkEntry(ClassicGroupState.STABLE, 1L),
            Utils.mkEntry(ClassicGroupState.DEAD, 1L)
        )));
        verify(context.metrics, times(1)).setConsumerGroupGauges(eq(Utils.mkMap(
            Utils.mkEntry(ConsumerGroup.ConsumerGroupState.EMPTY, 2L),
            Utils.mkEntry(ConsumerGroup.ConsumerGroupState.ASSIGNING, 1L),
            Utils.mkEntry(ConsumerGroup.ConsumerGroupState.STABLE, 1L)
        )));
    }

    @Test
    public void testUpdateStreamsGroupSizeCounter() {
        List<String> groupIds = new ArrayList<>();
        IntStream.range(0, 5).forEach(i -> groupIds.add("group-" + i));
        List<String> streamsMemberIds = List.of("streams-member-id-0", "streams-member-id-1", "streams-member-id-2", "streams-member-id-3");

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroup(new StreamsGroupBuilder(groupIds.get(0), 10)) // Empty group
            .withStreamsGroup(new StreamsGroupBuilder(groupIds.get(1), 10) // Stable group
                .withTargetAssignmentEpoch(10)
                .withTopology(new StreamsTopology(1, Map.of()))
                .withValidatedTopologyEpoch(1)
                .withMember(streamsGroupMemberBuilderWithDefaults(streamsMemberIds.get(0))
                    .setMemberEpoch(10)
                    .build()))
            .withStreamsGroup(new StreamsGroupBuilder(groupIds.get(2), 10) // Assigning group
                .withTargetAssignmentEpoch(9)
                .withTopology(new StreamsTopology(1, Map.of()))
                .withValidatedTopologyEpoch(1)
                .withMember(streamsGroupMemberBuilderWithDefaults(streamsMemberIds.get(1))
                    .setMemberEpoch(9)
                    .build()))
            .withStreamsGroup(new StreamsGroupBuilder(groupIds.get(3), 10) // Reconciling group
                .withTargetAssignmentEpoch(10)
                .withTopology(new StreamsTopology(1, Map.of()))
                .withValidatedTopologyEpoch(1)
                .withMember(streamsGroupMemberBuilderWithDefaults(streamsMemberIds.get(2))
                    .setMemberEpoch(9)
                    .build()))
            .withStreamsGroup(new StreamsGroupBuilder(groupIds.get(4), 10) // NotReady group
                .withTargetAssignmentEpoch(10)
                .withTopology(new StreamsTopology(1, Map.of()))
                .withMember(streamsGroupMemberBuilderWithDefaults(streamsMemberIds.get(3))
                    .build()))
            .build();

        context.groupMetadataManager.updateGroupSizeCounter();
        verify(context.metrics, times(1)).setStreamsGroupGauges(eq(Utils.mkMap(
            Utils.mkEntry(StreamsGroup.StreamsGroupState.EMPTY, 1L),
            Utils.mkEntry(StreamsGroup.StreamsGroupState.ASSIGNING, 1L),
            Utils.mkEntry(StreamsGroup.StreamsGroupState.RECONCILING, 1L),
            Utils.mkEntry(StreamsGroup.StreamsGroupState.NOT_READY, 1L),
            Utils.mkEntry(StreamsGroup.StreamsGroupState.STABLE, 1L)
        )));

        context.groupMetadataManager.getStreamsGroupOrThrow(groupIds.get(1))
            .removeMember(streamsMemberIds.get(0));
        context.groupMetadataManager.getStreamsGroupOrThrow(groupIds.get(3))
            .updateMember(streamsGroupMemberBuilderWithDefaults(streamsMemberIds.get(2)).setMemberEpoch(10).build());

        context.groupMetadataManager.updateGroupSizeCounter();
        verify(context.metrics, times(1)).setStreamsGroupGauges(eq(Utils.mkMap(
            Utils.mkEntry(StreamsGroup.StreamsGroupState.EMPTY, 2L),
            Utils.mkEntry(StreamsGroup.StreamsGroupState.ASSIGNING, 1L),
            Utils.mkEntry(StreamsGroup.StreamsGroupState.NOT_READY, 1L),
            Utils.mkEntry(StreamsGroup.StreamsGroupState.STABLE, 1L)
        )));
    }

    private StreamsGroupMember.Builder streamsGroupMemberBuilderWithDefaults(String memberId) {
        return new StreamsGroupMember.Builder(memberId)
            .setMemberEpoch(1)
            .setPreviousMemberEpoch(0)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setRackId(null)
            .setInstanceId(null)
            .setRebalanceTimeoutMs(1500)
            .setAssignedTasks(TasksTupleWithEpochs.EMPTY)
            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
            .setTopologyEpoch(0)
            .setClientTags(Map.of())
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setProcessId(DEFAULT_PROCESS_ID)
            .setUserEndpoint(null);
    }

    @Test
    public void testGenerateRecordsOnNewClassicGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request, true);
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.MEMBER_ID_REQUIRED.code(), joinResult.joinFuture.get().errorCode());

        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newEmptyGroupMetadataRecord(group)),
            joinResult.records
        );
    }

    @Test
    public void testGenerateRecordsOnNewClassicGroupFailureTransformsError() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId("group-instance-id")
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request, true);
        assertFalse(joinResult.joinFuture.isDone());

        // Simulate a failed write to the log.
        joinResult.appendFuture.completeExceptionally(new NotLeaderOrFollowerException());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NOT_COORDINATOR.code(), joinResult.joinFuture.get().errorCode());
    }

    @ParameterizedTest
    @ValueSource(booleans = {true, false})
    public void testReplayGroupMetadataRecords(boolean useDefaultRebalanceTimeout) {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        byte[] subscription = ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
            List.of("foo"))).array();
        List<GroupMetadataValue.MemberMetadata> members = new ArrayList<>();
        List<ClassicGroupMember> expectedMembers = new ArrayList<>();
        JoinGroupRequestProtocolCollection expectedProtocols = new JoinGroupRequestProtocolCollection(0);
        expectedProtocols.add(new JoinGroupRequestProtocol()
            .setName("range")
            .setMetadata(subscription));

        IntStream.range(0, 2).forEach(i -> {
            members.add(new GroupMetadataValue.MemberMetadata()
                .setMemberId("member-" + i)
                .setGroupInstanceId("group-instance-id-" + i)
                .setSubscription(subscription)
                .setAssignment(new byte[]{2})
                .setClientId("client-" + i)
                .setClientHost("host-" + i)
                .setSessionTimeout(4000)
                .setRebalanceTimeout(useDefaultRebalanceTimeout ? -1 : 9000)
            );

            expectedMembers.add(new ClassicGroupMember(
                "member-" + i,
                Optional.of("group-instance-id-" + i),
                "client-" + i,
                "host-" + i,
                useDefaultRebalanceTimeout ? 4000 : 9000,
                4000,
                "consumer",
                expectedProtocols,
                new byte[]{2}
            ));
        });

        CoordinatorRecord groupMetadataRecord = GroupMetadataManagerTestContext.newGroupMetadataRecord("group-id",
            new GroupMetadataValue()
                .setMembers(members)
                .setGeneration(1)
                .setLeader("member-0")
                .setProtocolType("consumer")
                .setProtocol("range")
                .setCurrentStateTimestamp(context.time.milliseconds()));

        context.replay(groupMetadataRecord);
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        ClassicGroup expectedGroup = new ClassicGroup(
            new LogContext(),
            "group-id",
            STABLE,
            context.time,
            1,
            Optional.of("consumer"),
            Optional.of("range"),
            Optional.of("member-0"),
            Optional.of(context.time.milliseconds())
        );
        expectedMembers.forEach(expectedGroup::add);

        assertEquals(expectedGroup.groupId(), group.groupId());
        assertEquals(expectedGroup.generationId(), group.generationId());
        assertEquals(expectedGroup.protocolType(), group.protocolType());
        assertEquals(expectedGroup.protocolName(), group.protocolName());
        assertEquals(expectedGroup.leaderOrNull(), group.leaderOrNull());
        assertEquals(expectedGroup.currentState(), group.currentState());
        assertEquals(expectedGroup.currentStateTimestampOrDefault(), group.currentStateTimestampOrDefault());
        assertEquals(expectedGroup.currentClassicGroupMembers(), group.currentClassicGroupMembers());
    }

    @Test
    public void testOnLoadedExceedGroupMaxSizeTriggersRebalance() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.GROUP_MAX_SIZE_CONFIG, 1)
            .build();

        byte[] subscription = ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
            List.of("foo"))).array();
        List<GroupMetadataValue.MemberMetadata> members = new ArrayList<>();

        IntStream.range(0, 2).forEach(i -> members.add(
            new GroupMetadataValue.MemberMetadata()
                .setMemberId("member-" + i)
                .setGroupInstanceId("group-instance-id-" + i)
                .setSubscription(subscription)
                .setAssignment(new byte[]{2})
                .setClientId("client-" + i)
                .setClientHost("host-" + i)
                .setSessionTimeout(4000)
                .setRebalanceTimeout(9000)
        ));

        CoordinatorRecord groupMetadataRecord = GroupMetadataManagerTestContext.newGroupMetadataRecord("group-id",
            new GroupMetadataValue()
                .setMembers(members)
                .setGeneration(1)
                .setLeader("member-0")
                .setProtocolType("consumer")
                .setProtocol("range")
                .setCurrentStateTimestamp(context.time.milliseconds()));

        context.replay(groupMetadataRecord);
        context.groupMetadataManager.onLoaded();
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        assertTrue(group.isInState(PREPARING_REBALANCE));
        assertEquals(2, group.numMembers());
    }

    @Test
    public void testOnLoadedSchedulesClassicGroupMemberHeartbeats() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        byte[] subscription = ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
            List.of("foo"))).array();
        List<GroupMetadataValue.MemberMetadata> members = new ArrayList<>();

        IntStream.range(0, 2).forEach(i -> members.add(
            new GroupMetadataValue.MemberMetadata()
                .setMemberId("member-" + i)
                .setGroupInstanceId("group-instance-id-" + i)
                .setSubscription(subscription)
                .setAssignment(new byte[]{2})
                .setClientId("client-" + i)
                .setClientHost("host-" + i)
                .setSessionTimeout(4000)
                .setRebalanceTimeout(9000)
            )
        );

        CoordinatorRecord groupMetadataRecord = GroupMetadataManagerTestContext.newGroupMetadataRecord("group-id",
            new GroupMetadataValue()
                .setMembers(members)
                .setGeneration(1)
                .setLeader("member-0")
                .setProtocolType("consumer")
                .setProtocol("range")
                .setCurrentStateTimestamp(context.time.milliseconds()));

        context.replay(groupMetadataRecord);
        context.groupMetadataManager.onLoaded();

        IntStream.range(0, 2).forEach(i -> {
            ScheduledTimeout<CoordinatorRecord> timeout = context.timer.timeout(
                classicGroupHeartbeatKey("group-id", "member-1"));

            assertNotNull(timeout);
            assertEquals(context.time.milliseconds() + 4000, timeout.deadlineMs());
        });
    }

    @Test
    public void testJoinGroupShouldReceiveErrorIfGroupOverMaxSize() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.GROUP_MAX_SIZE_CONFIG, 10)
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withReason("exceed max group size")
            .build();

        IntStream.range(0, 10).forEach(i -> {
            GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);
            assertFalse(joinResult.joinFuture.isDone());
            assertTrue(joinResult.records.isEmpty());
        });

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);
        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.GROUP_MAX_SIZE_REACHED.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testDynamicMembersJoinGroupWithMaxSizeAndRequiredKnownMember() {
        boolean requiredKnownMemberId = true;
        int groupMaxSize = 10;
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.GROUP_MAX_SIZE_CONFIG, groupMaxSize)
            .withConfig(GroupCoordinatorConfig.GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 50)
            .build();

        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        // First round of join requests. Generate member ids. All requests will be accepted
        // as the group is still Empty.
        List<GroupMetadataManagerTestContext.JoinResult> firstRoundJoinResults = IntStream.range(0, groupMaxSize + 1).mapToObj(i -> context.sendClassicGroupJoin(
            request,
            requiredKnownMemberId
        )).toList();

        List<String> memberIds = verifyClassicGroupJoinResponses(firstRoundJoinResults, 0, Errors.MEMBER_ID_REQUIRED);
        assertEquals(groupMaxSize + 1, memberIds.size());
        assertEquals(0, group.numMembers());
        assertTrue(group.isInState(EMPTY));
        assertEquals(groupMaxSize + 1, group.numPendingJoinMembers());

        // Second round of join requests with the generated member ids.
        // One of them will fail, reaching group max size.
        List<GroupMetadataManagerTestContext.JoinResult> secondRoundJoinResults = memberIds.stream().map(memberId -> context.sendClassicGroupJoin(
            request.setMemberId(memberId),
            requiredKnownMemberId
        )).toList();

        // Advance clock by group initial rebalance delay to complete first inital delayed join.
        // This will extend the initial rebalance as new members have joined.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(50));
        // Advance clock by group initial rebalance delay to complete second inital delayed join.
        // Since there are no new members that joined since the previous delayed join,
        // the join group phase will complete.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(50));

        verifyClassicGroupJoinResponses(secondRoundJoinResults, groupMaxSize, Errors.GROUP_MAX_SIZE_REACHED);
        assertEquals(groupMaxSize, group.numMembers());
        assertEquals(0, group.numPendingJoinMembers());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        // Members that were accepted can rejoin while others are rejected in CompletingRebalance state.
        List<GroupMetadataManagerTestContext.JoinResult> thirdRoundJoinResults = memberIds.stream().map(memberId -> context.sendClassicGroupJoin(
            request.setMemberId(memberId),
            requiredKnownMemberId
        )).toList();

        verifyClassicGroupJoinResponses(thirdRoundJoinResults, groupMaxSize, Errors.GROUP_MAX_SIZE_REACHED);
    }

    @Test
    public void testDynamicMembersJoinGroupWithMaxSizeAndNotRequiredKnownMember() {
        boolean requiredKnownMemberId = false;
        int groupMaxSize = 10;
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.GROUP_MAX_SIZE_CONFIG, groupMaxSize)
            .withConfig(GroupCoordinatorConfig.GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 50)
            .build();

        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        // First round of join requests. This will trigger a rebalance.
        List<GroupMetadataManagerTestContext.JoinResult> firstRoundJoinResults = IntStream.range(0, groupMaxSize + 1).mapToObj(i -> context.sendClassicGroupJoin(
            request,
            requiredKnownMemberId
        )).toList();

        assertEquals(groupMaxSize, group.numMembers());
        assertEquals(groupMaxSize, group.numAwaitingJoinResponse());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Advance clock by group initial rebalance delay to complete first inital delayed join.
        // This will extend the initial rebalance as new members have joined.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(50));
        // Advance clock by group initial rebalance delay to complete second inital delayed join.
        // Since there are no new members that joined since the previous delayed join,
        // we will complete the rebalance.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(50));

        List<String> memberIds = verifyClassicGroupJoinResponses(firstRoundJoinResults, groupMaxSize, Errors.GROUP_MAX_SIZE_REACHED);

        // Members that were accepted can rejoin while others are rejected in CompletingRebalance state.
        List<GroupMetadataManagerTestContext.JoinResult> secondRoundJoinResults = memberIds.stream().map(memberId -> context.sendClassicGroupJoin(
            request.setMemberId(memberId),
            requiredKnownMemberId
        )).toList();

        verifyClassicGroupJoinResponses(secondRoundJoinResults, 10, Errors.GROUP_MAX_SIZE_REACHED);
        assertEquals(groupMaxSize, group.numMembers());
        assertEquals(0, group.numAwaitingJoinResponse());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
    }

    @Test
    public void testStaticMembersJoinGroupWithMaxSize() {
        int groupMaxSize = 10;

        List<String> groupInstanceIds = IntStream.range(0, groupMaxSize + 1)
            .mapToObj(i -> "instance-id-" + i)
            .toList();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.GROUP_MAX_SIZE_CONFIG, groupMaxSize)
            .withConfig(GroupCoordinatorConfig.GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 50)
            .build();

        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        // First round of join requests. This will trigger a rebalance.
        List<GroupMetadataManagerTestContext.JoinResult> firstRoundJoinResults = groupInstanceIds.stream()
                                                                                                 .map(instanceId -> context.sendClassicGroupJoin(request.setGroupInstanceId(instanceId)))
                                                                                                 .toList();

        assertEquals(groupMaxSize, group.numMembers());
        assertEquals(groupMaxSize, group.numAwaitingJoinResponse());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Advance clock by group initial rebalance delay to complete first inital delayed join.
        // This will extend the initial rebalance as new members have joined.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(50));
        // Advance clock by group initial rebalance delay to complete second inital delayed join.
        // Since there are no new members that joined since the previous delayed join,
        // we will complete the rebalance.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(50));

        List<String> memberIds = verifyClassicGroupJoinResponses(firstRoundJoinResults, groupMaxSize, Errors.GROUP_MAX_SIZE_REACHED);

        // Members which were accepted can rejoin, others are rejected, while
        // completing rebalance
        List<GroupMetadataManagerTestContext.JoinResult> secondRoundJoinResults =  IntStream.range(0, groupMaxSize + 1).mapToObj(i -> context.sendClassicGroupJoin(
            request
                .setMemberId(memberIds.get(i))
                .setGroupInstanceId(groupInstanceIds.get(i))
        )).toList();

        verifyClassicGroupJoinResponses(secondRoundJoinResults, groupMaxSize, Errors.GROUP_MAX_SIZE_REACHED);
        assertEquals(groupMaxSize, group.numMembers());
        assertEquals(0, group.numAwaitingJoinResponse());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
    }

    @Test
    public void testDynamicMembersCanRejoinGroupWithMaxSizeWhileRebalancing() {
        boolean requiredKnownMemberId = true;
        int groupMaxSize = 10;
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.GROUP_MAX_SIZE_CONFIG, groupMaxSize)
            .withConfig(GroupCoordinatorConfig.GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 50)
            .build();

        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        // First round of join requests. Generate member ids.
        List<GroupMetadataManagerTestContext.JoinResult> firstRoundJoinResults =  IntStream.range(0, groupMaxSize + 1)
                                                                                           .mapToObj(__ -> context.sendClassicGroupJoin(request, requiredKnownMemberId))
                                                                                           .toList();

        assertEquals(0, group.numMembers());
        assertEquals(groupMaxSize + 1, group.numPendingJoinMembers());
        assertTrue(group.isInState(EMPTY));

        List<String> memberIds = verifyClassicGroupJoinResponses(firstRoundJoinResults, 0, Errors.MEMBER_ID_REQUIRED);
        assertEquals(groupMaxSize + 1, memberIds.size());

        // Second round of join requests with the generated member ids.
        // One of them will fail, reaching group max size.
        memberIds.forEach(memberId -> {
            GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
                request.setMemberId(memberId),
                requiredKnownMemberId
            );
            assertTrue(joinResult.records.isEmpty());
        });

        assertEquals(groupMaxSize, group.numMembers());
        assertEquals(groupMaxSize, group.numAwaitingJoinResponse());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Members can rejoin while rebalancing
        List<GroupMetadataManagerTestContext.JoinResult> thirdRoundJoinResults = memberIds.stream().map(memberId -> context.sendClassicGroupJoin(
            request.setMemberId(memberId),
            requiredKnownMemberId
        )).toList();

        // Advance clock by group initial rebalance delay to complete first inital delayed join.
        // This will extend the initial rebalance as new members have joined.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(50));
        // Advance clock by group initial rebalance delay to complete second inital delayed join.
        // Since there are no new members that joined since the previous delayed join,
        // we will complete the rebalance.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(50));

        verifyClassicGroupJoinResponses(thirdRoundJoinResults, groupMaxSize, Errors.GROUP_MAX_SIZE_REACHED);
        assertEquals(groupMaxSize, group.numMembers());
        assertEquals(0, group.numAwaitingJoinResponse());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
    }

    @Test
    public void testLastJoiningMembersAreKickedOutWhenRejoiningGroupWithMaxSize() {
        int groupMaxSize = 10;

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.GROUP_MAX_SIZE_CONFIG, groupMaxSize)
            .withConfig(GroupCoordinatorConfig.GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 50)
            .build();

        // Create a group and add members that exceed the group max size.
        ClassicGroup group = context.createClassicGroup("group-id");

        List<String> memberIds = IntStream.range(0, groupMaxSize + 2)
            .mapToObj(i -> group.generateMemberId("client-id", Optional.empty()))
            .toList();

        memberIds.forEach(memberId -> group.add(
            new ClassicGroupMember(
                memberId,
                Optional.empty(),
                "client-id",
                "client-host",
                10000,
                5000,
                "consumer",
                GroupMetadataManagerTestContext.toProtocols("range")
            )
        ));

        context.groupMetadataManager.prepareRebalance(group, "test");

        List<GroupMetadataManagerTestContext.JoinResult> joinResults = memberIds.stream().map(memberId -> context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(memberId)
                .withDefaultProtocolTypeAndProtocols()
                .withRebalanceTimeoutMs(10000)
                .build()
        )).toList();

        assertEquals(groupMaxSize, group.numMembers());
        assertEquals(groupMaxSize, group.numAwaitingJoinResponse());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Advance clock by rebalance timeout to complete join phase.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(10000));

        verifyClassicGroupJoinResponses(joinResults, groupMaxSize, Errors.GROUP_MAX_SIZE_REACHED);

        assertEquals(groupMaxSize, group.numMembers());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        memberIds.subList(groupMaxSize, groupMaxSize + 2)
            .forEach(memberId -> assertFalse(group.hasMember(memberId)));

        memberIds.subList(0, groupMaxSize)
            .forEach(memberId -> assertTrue(group.hasMember(memberId)));
    }

    @Test
    public void testJoinGroupUnknownMemberNewGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId("member-id")
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), joinResult.joinFuture.get().errorCode());

        // Static member
        request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId("member-id")
            .withGroupInstanceId("group-instance-id")
            .build();

        joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testClassicGroupJoinInconsistentProtocolType() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        context.joinClassicGroupAsDynamicMemberAndCompleteJoin(request);

        request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("connect")
            .withProtocols(GroupMetadataManagerTestContext.toProtocols("range"))
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.INCONSISTENT_GROUP_PROTOCOL.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testJoinGroupWithEmptyProtocolType() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("")
            .withProtocols(GroupMetadataManagerTestContext.toProtocols("range"))
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);
        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.INCONSISTENT_GROUP_PROTOCOL.code(), joinResult.joinFuture.get().errorCode());

        // Send as static member join.
        joinResult = context.sendClassicGroupJoin(request.setGroupInstanceId("group-instance-id"), true, true);
        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.INCONSISTENT_GROUP_PROTOCOL.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testJoinGroupWithEmptyGroupProtocol() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("consumer")
            .withProtocols(new JoinGroupRequestProtocolCollection(0))
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);
        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.INCONSISTENT_GROUP_PROTOCOL.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testNewMemberJoinExpiration() throws Exception {
        // This tests new member expiration during a protracted rebalance. We first create a
        // group with one member which uses a large value for session timeout and rebalance timeout.
        // We then join with one new member and let the rebalance hang while we await the first member.
        // The new member join timeout expires and its JoinGroup request is failed.

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withSessionTimeoutMs(5000 + context.classicGroupNewMemberJoinTimeoutMs)
            .withRebalanceTimeoutMs(2 * context.classicGroupNewMemberJoinTimeoutMs)
            .build();

        JoinGroupResponseData firstResponse = context.joinClassicGroupAsDynamicMemberAndCompleteJoin(request);
        String firstMemberId = firstResponse.memberId();
        assertEquals(firstResponse.leader(), firstMemberId);
        assertEquals(Errors.NONE.code(), firstResponse.errorCode());

        assertNotNull(group);
        assertEquals(0, group.allMembers().stream().filter(ClassicGroupMember::isNew).count());

        // Send second join group request for a new dynamic member.
        GroupMetadataManagerTestContext.JoinResult secondJoinResult = context.sendClassicGroupJoin(request
            .setSessionTimeoutMs(5000)
            .setRebalanceTimeoutMs(5000)
        );
        assertTrue(secondJoinResult.records.isEmpty());
        assertFalse(secondJoinResult.joinFuture.isDone());

        assertEquals(2, group.allMembers().size());
        assertEquals(1, group.allMembers().stream().filter(ClassicGroupMember::isNew).count());

        ClassicGroupMember newMember = group.allMembers().stream().filter(ClassicGroupMember::isNew).findFirst().get();
        assertNotEquals(firstMemberId, newMember.memberId());

        // Advance clock by new member join timeout to expire the second member.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupNewMemberJoinTimeoutMs));

        assertTrue(secondJoinResult.joinFuture.isDone());
        JoinGroupResponseData secondResponse = secondJoinResult.joinFuture.get();

        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), secondResponse.errorCode());
        assertEquals(1, group.allMembers().size());
        assertEquals(0, group.allMembers().stream().filter(ClassicGroupMember::isNew).count());
        assertEquals(firstMemberId, group.allMembers().iterator().next().memberId());
    }

    @Test
    public void testJoinGroupInconsistentGroupProtocol() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("consumer")
            .withProtocols(GroupMetadataManagerTestContext.toProtocols("range"))
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertFalse(joinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.JoinResult otherJoinResult = context.sendClassicGroupJoin(request.setProtocols(GroupMetadataManagerTestContext.toProtocols("roundrobin")));

        assertTrue(joinResult.records.isEmpty());
        assertTrue(otherJoinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs));
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(Errors.INCONSISTENT_GROUP_PROTOCOL.code(), otherJoinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testJoinGroupSecondJoinInconsistentProtocol() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request, true);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.MEMBER_ID_REQUIRED.code(), joinResult.joinFuture.get().errorCode());

        // Sending an inconsistent protocol should be refused
        String memberId = joinResult.joinFuture.get().memberId();
        JoinGroupRequestProtocolCollection emptyProtocols = new JoinGroupRequestProtocolCollection(0);
        request = request.setMemberId(memberId)
            .setProtocols(emptyProtocols);

        joinResult = context.sendClassicGroupJoin(request, true);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.INCONSISTENT_GROUP_PROTOCOL.code(), joinResult.joinFuture.get().errorCode());

        // Sending consistent protocol should be accepted
        joinResult = context.sendClassicGroupJoin(request.setProtocols(GroupMetadataManagerTestContext.toProtocols("range")), true);

        assertTrue(joinResult.records.isEmpty());
        assertFalse(joinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs));

        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testStaticMemberJoinAsFirstMember() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId("group-instance-id")
            .withDefaultProtocolTypeAndProtocols()
            .build();

        context.joinClassicGroupAndCompleteJoin(request, false, true);
    }

    @Test
    public void testStaticMemberRejoinWithExplicitUnknownMemberId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId("group-instance-id")
            .withDefaultProtocolTypeAndProtocols()
            .withSessionTimeoutMs(5000)
            .withRebalanceTimeoutMs(5000)
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAndCompleteJoin(request, false, true);
        assertEquals(Errors.NONE.code(), response.errorCode());

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            request.setMemberId("unknown-member-id")
        );

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.FENCED_INSTANCE_ID.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testJoinGroupUnknownConsumerExistingGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withSessionTimeoutMs(5000)
            .withRebalanceTimeoutMs(5000)
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAsDynamicMemberAndCompleteJoin(request);
        assertEquals(Errors.NONE.code(), response.errorCode());

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            request.setMemberId("other-member-id")
        );

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testJoinGroupUnknownConsumerNewDeadGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");
        group.transitionTo(DEAD);

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.COORDINATOR_NOT_AVAILABLE.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testJoinGroupProtocolTypeIsNotProvidedWhenAnErrorOccurs() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId("member-id")
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), joinResult.joinFuture.get().errorCode());
        assertNull(joinResult.joinFuture.get().protocolType());
    }

    @Test
    public void testJoinGroupReturnsTheProtocolType() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        // Leader joins
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(leaderJoinResult.records.isEmpty());
        assertFalse(leaderJoinResult.joinFuture.isDone());

        // Member joins
        GroupMetadataManagerTestContext.JoinResult memberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(memberJoinResult.records.isEmpty());
        assertFalse(memberJoinResult.joinFuture.isDone());

        // Complete join group phase
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs));
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(memberJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), leaderJoinResult.joinFuture.get().errorCode());
        assertEquals("consumer", leaderJoinResult.joinFuture.get().protocolType());
        assertEquals(Errors.NONE.code(), memberJoinResult.joinFuture.get().errorCode());
        assertEquals("consumer", memberJoinResult.joinFuture.get().protocolType());
    }

    @Test
    public void testDelayInitialRebalanceByGroupInitialRebalanceDelayOnEmptyGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertFalse(joinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs / 2));
        assertFalse(joinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs / 2 + 1));
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testResetRebalanceDelayWhenNewMemberJoinsGroupDuringInitialRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withRebalanceTimeoutMs(context.classicGroupInitialRebalanceDelayMs * 3)
            .build();

        GroupMetadataManagerTestContext.JoinResult firstMemberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(firstMemberJoinResult.records.isEmpty());
        assertFalse(firstMemberJoinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs - 1));
        GroupMetadataManagerTestContext.JoinResult secondMemberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(secondMemberJoinResult.records.isEmpty());
        assertFalse(secondMemberJoinResult.joinFuture.isDone());
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(2));

        // Advance clock past initial rebalance delay and verify futures are not completed.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs / 2 + 1));
        assertFalse(firstMemberJoinResult.joinFuture.isDone());
        assertFalse(secondMemberJoinResult.joinFuture.isDone());

        // Advance clock beyond recomputed delay and make sure the futures have completed.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs / 2));
        assertTrue(firstMemberJoinResult.joinFuture.isDone());
        assertTrue(secondMemberJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), firstMemberJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), secondMemberJoinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testDelayRebalanceUptoRebalanceTimeout() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withRebalanceTimeoutMs(context.classicGroupInitialRebalanceDelayMs * 2)
            .build();

        GroupMetadataManagerTestContext.JoinResult firstMemberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(firstMemberJoinResult.records.isEmpty());
        assertFalse(firstMemberJoinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.JoinResult secondMemberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(secondMemberJoinResult.records.isEmpty());
        assertFalse(secondMemberJoinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs + 1));

        GroupMetadataManagerTestContext.JoinResult thirdMemberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(thirdMemberJoinResult.records.isEmpty());
        assertFalse(thirdMemberJoinResult.joinFuture.isDone());

        // Advance clock right before rebalance timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs - 1));
        assertFalse(firstMemberJoinResult.joinFuture.isDone());
        assertFalse(secondMemberJoinResult.joinFuture.isDone());
        assertFalse(thirdMemberJoinResult.joinFuture.isDone());

        // Advance clock beyond rebalance timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(1));
        assertTrue(firstMemberJoinResult.joinFuture.isDone());
        assertTrue(secondMemberJoinResult.joinFuture.isDone());
        assertTrue(thirdMemberJoinResult.joinFuture.isDone());

        assertEquals(Errors.NONE.code(), firstMemberJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), secondMemberJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), thirdMemberJoinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testJoinGroupReplaceStaticMember() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId("group-instance-id")
            .withDefaultProtocolTypeAndProtocols()
            .withSessionTimeoutMs(5000)
            .build();

        // Send join group as static member.
        GroupMetadataManagerTestContext.JoinResult oldMemberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(oldMemberJoinResult.records.isEmpty());
        assertFalse(oldMemberJoinResult.joinFuture.isDone());
        assertEquals(1, group.numAwaitingJoinResponse());
        assertEquals(1, group.numMembers());

        // Replace static member with new member id. Old member id should be fenced.
        GroupMetadataManagerTestContext.JoinResult newMemberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(newMemberJoinResult.records.isEmpty());
        assertFalse(newMemberJoinResult.joinFuture.isDone());
        assertTrue(oldMemberJoinResult.joinFuture.isDone());
        assertEquals(Errors.FENCED_INSTANCE_ID.code(), oldMemberJoinResult.joinFuture.get().errorCode());
        assertEquals(1, group.numAwaitingJoinResponse());
        assertEquals(1, group.numMembers());

        // Complete join for new member.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs));
        assertTrue(newMemberJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), newMemberJoinResult.joinFuture.get().errorCode());
        assertEquals(0, group.numAwaitingJoinResponse());
        assertEquals(1, group.numMembers());
    }

    @Test
    public void testHeartbeatExpirationShouldRemovePendingMember() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withSessionTimeoutMs(1000)
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request, true);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.MEMBER_ID_REQUIRED.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(0, group.numMembers());
        assertEquals(1, group.numPendingJoinMembers());

        // Advance clock by session timeout. Pending member should be removed from group as heartbeat expires.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(1000));
        assertEquals(0, group.numPendingJoinMembers());
    }

    @Test
    public void testHeartbeatExpirationShouldRemoveMember() throws Exception {
        // Set initial rebalance delay to simulate a long running rebalance.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 10 * 60 * 1000)
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertFalse(joinResult.joinFuture.isDone());
        assertEquals(1, group.numMembers());

        String memberId = group.leaderOrNull();
        // Advance clock by new member join timeout. Member should be removed from group as heartbeat expires.
        // A group that transitions to Empty after completing join phase will generate records.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(context.classicGroupNewMemberJoinTimeoutMs);

        assertEquals(1, timeouts.size());
        timeouts.forEach(timeout -> {
            assertEquals(classicGroupHeartbeatKey("group-id", memberId), timeout.key());
            assertEquals(
                List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
                timeout.result().records()
            );
        });

        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(0, group.numMembers());
    }

    @Test
    public void testExistingMemberJoinDeadGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAsDynamicMemberAndCompleteJoin(request);
        assertEquals(Errors.NONE.code(), response.errorCode());
        String memberId = response.memberId();

        assertTrue(group.hasMember(memberId));

        group.transitionTo(DEAD);

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.COORDINATOR_NOT_AVAILABLE.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testJoinGroupExistingPendingMemberWithGroupInstanceIdThrowsException() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request, true);

        assertTrue(joinResult.records.isEmpty());
        assertEquals(Errors.MEMBER_ID_REQUIRED.code(), joinResult.joinFuture.get().errorCode());
        assertTrue(joinResult.joinFuture.isDone());
        String memberId = joinResult.joinFuture.get().memberId();

        assertThrows(IllegalStateException.class,
            () -> context.sendClassicGroupJoin(
                request
                    .setMemberId(memberId)
                    .setGroupInstanceId("group-instance-id")
            )
        );
    }

    @Test
    public void testJoinGroupExistingMemberUpdatedMetadataTriggersRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestProtocolCollection protocols = GroupMetadataManagerTestContext.toProtocols("range");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("consumer")
            .withProtocols(protocols)
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAsDynamicMemberAndCompleteJoin(request);

        assertEquals(Errors.NONE.code(), response.errorCode());
        String memberId = response.memberId();
        ClassicGroupMember member = group.member(memberId);

        assertEquals(protocols, member.supportedProtocols());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(1, group.generationId());

        protocols = GroupMetadataManagerTestContext.toProtocols("range", "roundrobin");

        // Send updated member metadata. This should trigger a rebalance and complete the join phase.
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            request
                .setMemberId(memberId)
                .setProtocols(protocols)
        );

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());

        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), response.errorCode());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(2, group.generationId());
        assertEquals(protocols, member.supportedProtocols());
    }

    @Test
    public void testJoinGroupAsExistingLeaderTriggersRebalanceInStableState() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAsDynamicMemberAndCompleteJoin(request);

        assertEquals(Errors.NONE.code(), response.errorCode());
        String memberId = response.memberId();

        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertTrue(group.isLeader(memberId));
        assertEquals(1, group.generationId());

        group.transitionTo(STABLE);
        // Sending join group as leader should trigger a rebalance.
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            request.setMemberId(memberId)
        );

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(2, group.generationId());
    }

    @Test
    public void testJoinGroupAsExistingMemberWithUpdatedMetadataTriggersRebalanceInStableState() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");


        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("consumer")
            .withProtocols(GroupMetadataManagerTestContext.toProtocols("range"))
            .build();

        JoinGroupResponseData leaderResponse = context.joinClassicGroupAsDynamicMemberAndCompleteJoin(request);
        assertEquals(Errors.NONE.code(), leaderResponse.errorCode());
        String leaderId = leaderResponse.leader();
        assertEquals(1, group.generationId());

        // Member joins.
        GroupMetadataManagerTestContext.JoinResult memberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(memberJoinResult.records.isEmpty());
        assertFalse(memberJoinResult.joinFuture.isDone());

        // Leader also rejoins. Completes join group phase.
        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(request.setMemberId(leaderId));

        assertTrue(leaderJoinResult.records.isEmpty());
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(memberJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), leaderJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), memberJoinResult.joinFuture.get().errorCode());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(2, group.numMembers());
        assertEquals(2, group.generationId());

        group.transitionTo(STABLE);

        // Member rejoins with updated metadata. This should trigger a rebalance.
        String memberId = memberJoinResult.joinFuture.get().memberId();

        JoinGroupRequestProtocolCollection protocols = GroupMetadataManagerTestContext.toProtocols("range", "roundrobin");

        memberJoinResult = context.sendClassicGroupJoin(
            request
                .setMemberId(memberId)
                .setProtocols(protocols)
        );

        assertTrue(memberJoinResult.records.isEmpty());
        assertFalse(memberJoinResult.joinFuture.isDone());

        // Leader rejoins. This completes the join group phase.
        leaderJoinResult = context.sendClassicGroupJoin(request.setMemberId(leaderId));

        assertTrue(leaderJoinResult.records.isEmpty());
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(memberJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), memberJoinResult.joinFuture.get().errorCode());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(3, group.generationId());
        assertEquals(2, group.numMembers());
    }

    @Test
    public void testJoinGroupExistingMemberDoesNotTriggerRebalanceInStableState() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        JoinGroupResponseData leaderResponse = context.joinClassicGroupAsDynamicMemberAndCompleteJoin(request);
        assertEquals(Errors.NONE.code(), leaderResponse.errorCode());
        String leaderId = leaderResponse.leader();
        assertEquals(1, group.generationId());

        // Member joins.
        GroupMetadataManagerTestContext.JoinResult memberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(memberJoinResult.records.isEmpty());
        assertFalse(memberJoinResult.joinFuture.isDone());

        // Leader also rejoins. Completes join group phase.
        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(request.setMemberId(leaderId));

        assertTrue(leaderJoinResult.records.isEmpty());
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(memberJoinResult.joinFuture.isDone());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(2, group.numMembers());
        assertEquals(2, group.generationId());

        String memberId = memberJoinResult.joinFuture.get().memberId();

        group.transitionTo(STABLE);

        // Member rejoins with no metadata changes. This does not trigger a rebalance.
        memberJoinResult = context.sendClassicGroupJoin(request.setMemberId(memberId));

        assertTrue(memberJoinResult.records.isEmpty());
        assertTrue(memberJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), memberJoinResult.joinFuture.get().errorCode());
        assertEquals(2, memberJoinResult.joinFuture.get().generationId());
        assertTrue(group.isInState(STABLE));
    }

    @Test
    public void testJoinGroupExistingMemberInEmptyState() throws Exception {
        // Existing member joins a group that is in Empty/Dead state. Ask member to rejoin with generation id reset.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAsDynamicMemberAndCompleteJoin(request);

        assertEquals(Errors.NONE.code(), response.errorCode());
        String memberId = response.memberId();

        assertTrue(group.isInState(COMPLETING_REBALANCE));

        group.transitionTo(PREPARING_REBALANCE);
        group.transitionTo(EMPTY);

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request.setMemberId(memberId));

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(-1, joinResult.joinFuture.get().generationId());
    }

    @Test
    public void testCompleteJoinRemoveNotYetRejoinedDynamicMembers() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withSessionTimeoutMs(1000)
            .withRebalanceTimeoutMs(1000)
            .build();

        JoinGroupResponseData leaderResponse = context.joinClassicGroupAsDynamicMemberAndCompleteJoin(request);
        assertEquals(Errors.NONE.code(), leaderResponse.errorCode());
        assertEquals(1, group.generationId());

        // Add new member. This triggers a rebalance.
        GroupMetadataManagerTestContext.JoinResult memberJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(memberJoinResult.records.isEmpty());
        assertFalse(memberJoinResult.joinFuture.isDone());
        assertEquals(2, group.numMembers());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Advance clock by rebalance timeout. This will expire the leader as it has not rejoined.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(1000));

        assertTrue(memberJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), memberJoinResult.joinFuture.get().errorCode());
        assertEquals(1, group.numMembers());
        assertTrue(group.hasMember(memberJoinResult.joinFuture.get().memberId()));
        assertEquals(2, group.generationId());
    }

    @Test
    public void testCompleteJoinPhaseInEmptyStateSkipsRebalance() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withSessionTimeoutMs(1000)
            .withRebalanceTimeoutMs(1000)
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertFalse(joinResult.joinFuture.isDone());

        assertEquals(0, group.generationId());
        assertTrue(group.isInState(PREPARING_REBALANCE));
        group.transitionTo(DEAD);

        // Advance clock by initial rebalance delay to complete join phase.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs));
        assertEquals(0, group.generationId());
    }

    @Test
    public void testCompleteJoinPhaseNoMembersRejoinedExtendsJoinPhase() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("first-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withSessionTimeoutMs(30000)
            .withRebalanceTimeoutMs(10000)
            .build();

        // First member joins group and completes join phase.
        JoinGroupResponseData firstMemberResponse = context.joinClassicGroupAndCompleteJoin(request, true, true);
        assertEquals(Errors.NONE.code(), firstMemberResponse.errorCode());
        String firstMemberId = firstMemberResponse.memberId();

        // Second member joins and group goes into rebalancing state.
        GroupMetadataManagerTestContext.JoinResult secondMemberJoinResult = context.sendClassicGroupJoin(
            request.setGroupInstanceId("second-instance-id")
        );

        assertTrue(secondMemberJoinResult.records.isEmpty());
        assertFalse(secondMemberJoinResult.joinFuture.isDone());

        // First static member rejoins and completes join phase.
        GroupMetadataManagerTestContext.JoinResult firstMemberJoinResult = context.sendClassicGroupJoin(
            request.setMemberId(firstMemberId).setGroupInstanceId("first-instance-id"));

        assertTrue(firstMemberJoinResult.records.isEmpty());
        assertTrue(firstMemberJoinResult.joinFuture.isDone());
        assertTrue(secondMemberJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), firstMemberJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), secondMemberJoinResult.joinFuture.get().errorCode());
        assertEquals(2, group.numMembers());
        assertEquals(2, group.generationId());

        String secondMemberId = secondMemberJoinResult.joinFuture.get().memberId();

        // Trigger a rebalance. No members rejoined.
        context.groupMetadataManager.prepareRebalance(group, "trigger rebalance");

        assertEquals(2, group.numMembers());
        assertTrue(group.isInState(PREPARING_REBALANCE));
        assertEquals(0, group.numAwaitingJoinResponse());

        // Advance clock by rebalance timeout to complete join phase. As long as both members have not
        // rejoined, we extend the join phase.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(10000));
        assertEquals(10000, context.timer.timeout("join-group-id").deadlineMs() - context.time.milliseconds());
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(10000));
        assertEquals(10000, context.timer.timeout("join-group-id").deadlineMs() - context.time.milliseconds());

        assertTrue(group.isInState(PREPARING_REBALANCE));
        assertEquals(2, group.numMembers());
        assertEquals(2, group.generationId());

        // Let first and second member rejoin. This should complete the join phase.
        firstMemberJoinResult = context.sendClassicGroupJoin(
            request
                .setMemberId(firstMemberId)
                .setGroupInstanceId("first-instance-id")
        );

        assertTrue(firstMemberJoinResult.records.isEmpty());
        assertFalse(firstMemberJoinResult.joinFuture.isDone());
        assertTrue(group.isInState(PREPARING_REBALANCE));
        assertEquals(2, group.numMembers());
        assertEquals(2, group.generationId());

        secondMemberJoinResult = context.sendClassicGroupJoin(
            request
                .setMemberId(secondMemberId)
                .setGroupInstanceId("second-instance-id")
        );

        assertTrue(secondMemberJoinResult.records.isEmpty());
        assertTrue(firstMemberJoinResult.joinFuture.isDone());
        assertTrue(secondMemberJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), firstMemberJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), secondMemberJoinResult.joinFuture.get().errorCode());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(2, group.numMembers());
        assertEquals(3, group.generationId());
    }

    @ParameterizedTest
    @ValueSource(booleans = {true, false})
    public void testReplaceStaticMemberInStableStateNoError(
        boolean supportSkippingAssignment
    ) throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("group-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("consumer")
            .withProtocols(GroupMetadataManagerTestContext.toProtocols("range"))
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAndCompleteJoin(request, true, supportSkippingAssignment);
        assertEquals(Errors.NONE.code(), response.errorCode());
        String oldMemberId = response.memberId();

        assertEquals(1, group.numMembers());
        assertEquals(1, group.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        // Simulate successful sync group phase
        group.transitionTo(STABLE);

        // Static member rejoins with UNKNOWN_MEMBER_ID. This should update the log with the generated member id.
        JoinGroupRequestProtocolCollection protocols = GroupMetadataManagerTestContext.toProtocols("range", "roundrobin");

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            request
                .setProtocols(protocols)
                .setRebalanceTimeoutMs(7000)
                .setSessionTimeoutMs(4500),
            true,
            supportSkippingAssignment
        );

        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
            joinResult.records
        );
        assertFalse(joinResult.joinFuture.isDone());

        // Write was successful.
        joinResult.appendFuture.complete(null);
        assertTrue(joinResult.joinFuture.isDone());

        String newMemberId = group.staticMemberId("group-instance-id");

        JoinGroupResponseData expectedResponse = new JoinGroupResponseData()
            .setMembers(List.of())
            .setLeader(oldMemberId)
            .setMemberId(newMemberId)
            .setGenerationId(1)
            .setProtocolType("consumer")
            .setProtocolName("range")
            .setSkipAssignment(supportSkippingAssignment)
            .setErrorCode(Errors.NONE.code());

        if (supportSkippingAssignment) {
            expectedResponse
                .setMembers(List.of(
                    new JoinGroupResponseData.JoinGroupResponseMember()
                        .setMemberId(newMemberId)
                        .setGroupInstanceId("group-instance-id")
                        .setMetadata(protocols.find("range").metadata())
                    ))
                .setLeader(newMemberId);
        }

        ClassicGroupMember updatedMember = group.member(group.staticMemberId("group-instance-id"));

        assertEquals(expectedResponse, joinResult.joinFuture.get());
        assertEquals(newMemberId, updatedMember.memberId());
        assertEquals(Optional.of("group-instance-id"), updatedMember.groupInstanceId());
        assertEquals(7000, updatedMember.rebalanceTimeoutMs());
        assertEquals(4500, updatedMember.sessionTimeoutMs());
        assertEquals(protocols, updatedMember.supportedProtocols());

        assertEquals(1, group.numMembers());
        assertEquals(1, group.generationId());
        assertTrue(group.isInState(STABLE));
    }

    @Test
    public void testReplaceStaticMemberInStableStateWithUpdatedProtocolTriggersRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("group-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("consumer")
            .withProtocols(GroupMetadataManagerTestContext.toProtocols("range", "roundrobin"))
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAndCompleteJoin(request, true, true);
        assertEquals(Errors.NONE.code(), response.errorCode());
        assertEquals(1, group.numMembers());
        assertEquals(1, group.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        // Simulate successful sync group phase
        group.transitionTo(STABLE);

        // Static member rejoins with UNKNOWN_MEMBER_ID. The selected protocol changes and triggers a rebalance.
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            request.setProtocols(GroupMetadataManagerTestContext.toProtocols("roundrobin"))
        );

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(1, group.numMembers());
        assertEquals(2, group.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
    }

    @Test
    public void testReplaceStaticMemberInStableStateErrors() throws Exception {
        // If the append future fails, confirm that the member is not updated.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestProtocolCollection protocols = GroupMetadataManagerTestContext.toProtocols("range");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("group-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("consumer")
            .withProtocols(protocols)
            .withRebalanceTimeoutMs(4000)
            .withSessionTimeoutMs(3000)
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAndCompleteJoin(request, false, false);
        assertEquals(Errors.NONE.code(), response.errorCode());
        assertEquals(1, group.numMembers());
        assertEquals(1, group.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        String oldMemberId = response.memberId();
        // Simulate successful sync group phase
        group.transitionTo(STABLE);

        // Static member rejoins with UNKNOWN_MEMBER_ID but the append fails. The group should not be updated.
        protocols.add(new JoinGroupRequestProtocol()
                .setName("roundrobin")
                .setMetadata(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of("bar"))).array()));

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            request
                .setProtocols(protocols)
                .setRebalanceTimeoutMs(7000)
                .setSessionTimeoutMs(6000),
            false,
            false
        );

        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
            joinResult.records
        );
        assertFalse(joinResult.joinFuture.isDone());

        // Simulate a failed write to the log.
        joinResult.appendFuture.completeExceptionally(new UnknownTopicOrPartitionException());
        assertTrue(joinResult.joinFuture.isDone());

        JoinGroupResponseData expectedResponse = new JoinGroupResponseData()
            .setMembers(List.of())
            .setLeader(oldMemberId)
            .setMemberId(UNKNOWN_MEMBER_ID)
            .setGenerationId(1)
            .setProtocolType("consumer")
            .setProtocolName("range")
            .setSkipAssignment(false)
            .setErrorCode(Errors.COORDINATOR_NOT_AVAILABLE.code());

        assertEquals(expectedResponse, joinResult.joinFuture.get());

        ClassicGroupMember revertedMember = group.member(group.staticMemberId("group-instance-id"));

        assertEquals(oldMemberId, revertedMember.memberId());
        assertEquals(Optional.of("group-instance-id"), revertedMember.groupInstanceId());
        assertEquals(4000, revertedMember.rebalanceTimeoutMs());
        assertEquals(3000, revertedMember.sessionTimeoutMs());
        assertEquals(protocols, revertedMember.supportedProtocols());
        assertEquals(1, group.numMembers());
        assertEquals(1, group.generationId());
        assertTrue(group.isInState(STABLE));
    }

    @ParameterizedTest
    @ValueSource(booleans = {true, false})
    public void testReplaceStaticMemberInStableStateSucceeds(
        boolean supportSkippingAssignment
    ) throws Exception {
        // If the append future succeeds, the soft state is updated with the new member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("group-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("consumer")
            .withProtocols(GroupMetadataManagerTestContext.toProtocols("range"))
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAndCompleteJoin(
            request,
            true,
            supportSkippingAssignment
        );

        assertEquals(Errors.NONE.code(), response.errorCode());
        assertEquals(1, group.numMembers());
        assertEquals(1, group.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        String oldMemberId = response.memberId();
        // Simulate successful sync group phase
        group.transitionTo(STABLE);

        // Static member rejoins with UNKNOWN_MEMBER_ID and the append succeeds.
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            request
                .setProtocols(GroupMetadataManagerTestContext.toProtocols("range", "roundrobin"))
                .setRebalanceTimeoutMs(7000)
                .setSessionTimeoutMs(6000),
            true,
            supportSkippingAssignment);

        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
            joinResult.records
        );
        assertFalse(joinResult.joinFuture.isDone());

        // Simulate a successful write to the log.
        joinResult.appendFuture.complete(null);
        assertTrue(joinResult.joinFuture.isDone());

        JoinGroupResponseData expectedResponse = new JoinGroupResponseData()
            .setMembers(supportSkippingAssignment ? toJoinResponseMembers(group) : List.of())
            .setLeader(supportSkippingAssignment ? joinResult.joinFuture.get().memberId() : oldMemberId)
            .setMemberId(joinResult.joinFuture.get().memberId())
            .setGenerationId(1)
            .setProtocolType("consumer")
            .setProtocolName("range")
            .setSkipAssignment(supportSkippingAssignment)
            .setErrorCode(Errors.NONE.code());

        assertEquals(expectedResponse, joinResult.joinFuture.get());

        ClassicGroupMember newMember = group.member(group.staticMemberId("group-instance-id"));

        assertNotEquals(oldMemberId, newMember.memberId());
        assertEquals(Optional.of("group-instance-id"), newMember.groupInstanceId());
        assertEquals(7000, newMember.rebalanceTimeoutMs());
        assertEquals(6000, newMember.sessionTimeoutMs());
        assertEquals(GroupMetadataManagerTestContext.toProtocols("range", "roundrobin"), newMember.supportedProtocols());
        assertEquals(1, group.numMembers());
        assertEquals(1, group.generationId());
        assertTrue(group.isInState(STABLE));
    }

    @Test
    public void testReplaceStaticMemberInCompletingRebalanceStateTriggersRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("group-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        JoinGroupResponseData response = context.joinClassicGroupAndCompleteJoin(request, true, true);
        assertEquals(Errors.NONE.code(), response.errorCode());

        assertEquals(1, group.numMembers());
        assertEquals(1, group.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        // Static member rejoins with UNKNOWN_MEMBER_ID and triggers a rebalance.
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(1, group.numMembers());
        assertEquals(2, group.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
    }

    @Test
    public void testJoinGroupAppendErrorConversion() {
        assertEquals(Errors.COORDINATOR_NOT_AVAILABLE, appendGroupMetadataErrorToResponseError(Errors.UNKNOWN_TOPIC_OR_PARTITION));
        assertEquals(Errors.COORDINATOR_NOT_AVAILABLE, appendGroupMetadataErrorToResponseError(Errors.NOT_ENOUGH_REPLICAS));
        assertEquals(Errors.COORDINATOR_NOT_AVAILABLE, appendGroupMetadataErrorToResponseError(Errors.REQUEST_TIMED_OUT));

        assertEquals(Errors.NOT_COORDINATOR, appendGroupMetadataErrorToResponseError(Errors.NOT_LEADER_OR_FOLLOWER));
        assertEquals(Errors.NOT_COORDINATOR, appendGroupMetadataErrorToResponseError(Errors.KAFKA_STORAGE_ERROR));

        assertEquals(Errors.UNKNOWN_SERVER_ERROR, appendGroupMetadataErrorToResponseError(Errors.MESSAGE_TOO_LARGE));
        assertEquals(Errors.UNKNOWN_SERVER_ERROR, appendGroupMetadataErrorToResponseError(Errors.RECORD_LIST_TOO_LARGE));
        assertEquals(Errors.UNKNOWN_SERVER_ERROR, appendGroupMetadataErrorToResponseError(Errors.INVALID_FETCH_SIZE));

        assertEquals(Errors.LEADER_NOT_AVAILABLE, appendGroupMetadataErrorToResponseError(Errors.LEADER_NOT_AVAILABLE));
    }

    @Test
    public void testNewMemberTimeoutCompletion() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withDefaultProtocolTypeAndProtocols()
                .withSessionTimeoutMs(context.classicGroupNewMemberJoinTimeoutMs + 5000)
                .build()
        );

        assertTrue(joinResult.records.isEmpty());
        assertFalse(joinResult.joinFuture.isDone());

        // Advance clock by initial rebalance delay to complete join phase.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupInitialRebalanceDelayMs));

        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(1, group.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        assertEquals(0, group.allMembers().stream().filter(ClassicGroupMember::isNew).count());

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(joinResult.joinFuture.get().memberId())
                .withGenerationId(joinResult.joinFuture.get().generationId())
                .build()
        );

        // Simulate a successful write to the log.
        syncResult.appendFuture.complete(null);

        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertEquals(1, group.numMembers());

        // Make sure the NewMemberTimeout is not still in effect, and the member is not kicked
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(context.classicGroupNewMemberJoinTimeoutMs));
        assertEquals(1, group.numMembers());

        // Member should be removed as heartbeat expires. The group is now empty.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(5000);
        List<CoordinatorRecord> expectedRecords = List.of(GroupMetadataManagerTestContext.newGroupMetadataRecord(
            group.groupId(),
            new GroupMetadataValue()
                .setMembers(List.of())
                .setGeneration(2)
                .setLeader(null)
                .setProtocolType("consumer")
                .setProtocol(null)
                .setCurrentStateTimestamp(context.time.milliseconds()))
        );

        assertEquals(1, timeouts.size());
        String memberId = joinResult.joinFuture.get().memberId();
        timeouts.forEach(timeout -> {
            assertEquals(classicGroupHeartbeatKey("group-id", memberId), timeout.key());
            assertEquals(expectedRecords, timeout.result().records());
        });

        assertEquals(0, group.numMembers());
        assertTrue(group.isInState(EMPTY));
    }

    @Test
    public void testNewMemberFailureAfterJoinGroupCompletion() throws Exception {
        // For old versions of the JoinGroup protocol, new members were subject
        // to expiration if the rebalance took long enough. This test case ensures
        // that following completion of the JoinGroup phase, new members follow
        // normal heartbeat expiration logic.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withSessionTimeoutMs(5000)
            .withRebalanceTimeoutMs(10000)
            .build();

        JoinGroupResponseData joinResponse = context.joinClassicGroupAndCompleteJoin(joinRequest, false, false);
        assertEquals(Errors.NONE.code(), joinResponse.errorCode());

        String memberId = joinResponse.memberId();
        assertEquals(memberId, joinResponse.leader());
        assertEquals(1, joinResponse.generationId());

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(memberId)
                .withGenerationId(1)
                .build()
        );

        // Simulate a successful write to the log.
        syncResult.appendFuture.complete(null);

        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());

        assertTrue(group.isInState(STABLE));
        assertEquals(1, group.generationId());

        GroupMetadataManagerTestContext.JoinResult otherJoinResult = context.sendClassicGroupJoin(joinRequest.setMemberId(UNKNOWN_MEMBER_ID));
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(joinRequest.setMemberId(memberId));

        assertTrue(otherJoinResult.records.isEmpty());
        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertTrue(otherJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), otherJoinResult.joinFuture.get().errorCode());

        context.verifySessionExpiration(group, 5000);
    }

    @Test
    public void testStaticMemberFenceDuplicateRejoinedFollower() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // A third member joins. Trigger a rebalance.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        context.sendClassicGroupJoin(request);

        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Old follower rejoins group will be matching current member.id.
        GroupMetadataManagerTestContext.JoinResult oldFollowerJoinResult = context.sendClassicGroupJoin(
            request
                .setMemberId(rebalanceResult.followerId)
                .setGroupInstanceId("follower-instance-id")
        );

        assertTrue(oldFollowerJoinResult.records.isEmpty());
        assertFalse(oldFollowerJoinResult.joinFuture.isDone());

        // Duplicate follower joins group with unknown member id will trigger member id replacement.
        context.sendClassicGroupJoin(
            request
                .setMemberId(UNKNOWN_MEMBER_ID)
                .setGroupInstanceId("follower-instance-id")
        );

        // Old member shall be fenced immediately upon duplicate follower joins.
        assertTrue(oldFollowerJoinResult.records.isEmpty());
        assertTrue(oldFollowerJoinResult.joinFuture.isDone());

        JoinGroupResponseData expectedResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.FENCED_INSTANCE_ID.code())
            .setProtocolName(null)
            .setProtocolType(null)
            .setLeader(UNKNOWN_MEMBER_ID)
            .setMemberId(rebalanceResult.followerId)
            .setGenerationId(-1);

        checkJoinGroupResponse(
            expectedResponse,
            oldFollowerJoinResult.joinFuture.get(),
            group,
            PREPARING_REBALANCE,
            Set.of()
        );
    }

    @Test
    public void testStaticMemberFenceDuplicateSyncingFollowerAfterMemberIdChanged() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // Known leader rejoins will trigger rebalance.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(rebalanceResult.leaderId)
            .withProtocolSuperset()
            .withRebalanceTimeoutMs(10000)
            .build();

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(leaderJoinResult.records.isEmpty());
        assertFalse(leaderJoinResult.joinFuture.isDone());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Old follower rejoins group will match current member.id.
        GroupMetadataManagerTestContext.JoinResult oldFollowerJoinResult = context.sendClassicGroupJoin(
            request
                .setMemberId(rebalanceResult.followerId)
                .setGroupInstanceId("follower-instance-id")
        );

        assertTrue(oldFollowerJoinResult.records.isEmpty());
        assertTrue(oldFollowerJoinResult.joinFuture.isDone());
        assertTrue(leaderJoinResult.joinFuture.isDone());

        JoinGroupResponseData expectedLeaderResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(rebalanceResult.leaderId)
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setMembers(toJoinResponseMembers(group));

        checkJoinGroupResponse(
            expectedLeaderResponse,
            leaderJoinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of("leader-instance-id", "follower-instance-id")
        );

        assertEquals(rebalanceResult.leaderId, leaderJoinResult.joinFuture.get().memberId());
        assertEquals(rebalanceResult.leaderId, leaderJoinResult.joinFuture.get().leader());

        // Old follower should get a successful join group response.
        assertTrue(oldFollowerJoinResult.joinFuture.isDone());

        JoinGroupResponseData expectedFollowerResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(oldFollowerJoinResult.joinFuture.get().memberId())
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer");

        checkJoinGroupResponse(
            expectedFollowerResponse,
            oldFollowerJoinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of()
        );

        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(rebalanceResult.followerId, oldFollowerJoinResult.joinFuture.get().memberId());
        assertEquals(rebalanceResult.leaderId, oldFollowerJoinResult.joinFuture.get().leader());

        // Duplicate follower joins group with unknown member id will trigger member.id replacement,
        // and will also trigger a rebalance under CompletingRebalance state; the old follower sync callback
        // will return fenced exception while broker replaces the member identity with the duplicate follower joins.
        GroupMetadataManagerTestContext.SyncResult oldFollowerSyncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withGroupInstanceId("follower-instance-id")
                .withGenerationId(oldFollowerJoinResult.joinFuture.get().generationId())
                .withMemberId(oldFollowerJoinResult.joinFuture.get().memberId())
                .build()
        );

        assertTrue(oldFollowerSyncResult.records.isEmpty());
        assertFalse(oldFollowerSyncResult.syncFuture.isDone());

        GroupMetadataManagerTestContext.JoinResult duplicateFollowerJoinResult = context.sendClassicGroupJoin(
            request
                .setMemberId(UNKNOWN_MEMBER_ID)
                .setGroupInstanceId("follower-instance-id")
        );

        assertTrue(duplicateFollowerJoinResult.records.isEmpty());
        assertTrue(group.isInState(PREPARING_REBALANCE));
        assertFalse(duplicateFollowerJoinResult.joinFuture.isDone());
        assertTrue(oldFollowerSyncResult.syncFuture.isDone());
        assertEquals(Errors.FENCED_INSTANCE_ID.code(), oldFollowerSyncResult.syncFuture.get().errorCode());

        // Advance clock by rebalance timeout so that the join phase completes with duplicate follower.
        // Both heartbeats will expire but only the leader is kicked out.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(10000);
        assertEquals(2, timeouts.size());
        timeouts.forEach(timeout -> assertEquals(EMPTY_RESULT, timeout.result()));

        assertTrue(duplicateFollowerJoinResult.joinFuture.isDone());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(3, group.generationId());
        assertEquals(1, group.numMembers());
        assertTrue(group.hasMember(duplicateFollowerJoinResult.joinFuture.get().memberId()));
        assertEquals(duplicateFollowerJoinResult.joinFuture.get().memberId(), duplicateFollowerJoinResult.joinFuture.get().leader());
    }

    @Test
    public void testStaticMemberFenceDuplicateRejoiningFollowerAfterMemberIdChanged() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // Known leader rejoins will trigger rebalance.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(rebalanceResult.leaderId)
            .withDefaultProtocolTypeAndProtocols()
            .withRebalanceTimeoutMs(10000)
            .build();

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(request);

        assertTrue(leaderJoinResult.records.isEmpty());
        assertFalse(leaderJoinResult.joinFuture.isDone());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Duplicate follower joins group will trigger member id replacement.
        GroupMetadataManagerTestContext.JoinResult duplicateFollowerJoinResult = context.sendClassicGroupJoin(
            request
                .setMemberId(UNKNOWN_MEMBER_ID)
                .setGroupInstanceId("follower-instance-id")
        );

        assertTrue(duplicateFollowerJoinResult.records.isEmpty());
        assertTrue(duplicateFollowerJoinResult.joinFuture.isDone());

        // Old follower rejoins group will fail because member id is already updated.
        GroupMetadataManagerTestContext.JoinResult oldFollowerJoinResult = context.sendClassicGroupJoin(
            request.setMemberId(rebalanceResult.followerId)
        );

        assertTrue(oldFollowerJoinResult.records.isEmpty());
        assertTrue(oldFollowerJoinResult.joinFuture.isDone());
        assertTrue(leaderJoinResult.joinFuture.isDone());

        JoinGroupResponseData expectedLeaderResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(rebalanceResult.leaderId)
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setMembers(toJoinResponseMembers(group));

        checkJoinGroupResponse(
            expectedLeaderResponse,
            leaderJoinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of("leader-instance-id", "follower-instance-id")
        );

        assertTrue(duplicateFollowerJoinResult.joinFuture.isDone());

        JoinGroupResponseData expectedDuplicateFollowerResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(duplicateFollowerJoinResult.joinFuture.get().memberId())
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setMembers(List.of());

        checkJoinGroupResponse(
            expectedDuplicateFollowerResponse,
            duplicateFollowerJoinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of()
        );

        assertTrue(duplicateFollowerJoinResult.joinFuture.isDone());

        JoinGroupResponseData expectedOldFollowerResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.FENCED_INSTANCE_ID.code())
            .setGenerationId(-1)
            .setMemberId(rebalanceResult.followerId)
            .setLeader(UNKNOWN_MEMBER_ID)
            .setProtocolName(null)
            .setProtocolType(null)
            .setMembers(List.of());

        checkJoinGroupResponse(
            expectedOldFollowerResponse,
            oldFollowerJoinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of()
        );
    }

    @Test
    public void testStaticMemberRejoinWithKnownMemberId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId("group-instance-id")
            .withDefaultProtocolTypeAndProtocols()
            .build();

        JoinGroupResponseData joinResponse = context.joinClassicGroupAndCompleteJoin(request, false, false);
        assertEquals(Errors.NONE.code(), joinResponse.errorCode());

        String memberId = joinResponse.memberId();

        GroupMetadataManagerTestContext.JoinResult rejoinResult = context.sendClassicGroupJoin(
            request.setMemberId(memberId)
        );

        // The second join group should return immediately since we are using the same metadata during CompletingRebalance.
        assertTrue(rejoinResult.records.isEmpty());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertTrue(rejoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), rejoinResult.joinFuture.get().errorCode());

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(memberId)
                .withGenerationId(joinResponse.generationId())
                .withGroupInstanceId("group-instance-id")
                .build()
        );

        // Successful write to the log.
        syncResult.appendFuture.complete(null);

        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertTrue(group.isInState(STABLE));
    }

    @ParameterizedTest
    @ValueSource(booleans = {true, false})
    public void testStaticMemberRejoinWithLeaderIdAndUnknownMemberId(
        boolean supportSkippingAssignment
    ) throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // A static leader rejoin with unknown id will not trigger rebalance, and no assignment will be returned.
        // As the group was in Stable state and the member id was updated, this will generate records.
        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolSuperset()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            joinRequest,
            true,
            supportSkippingAssignment
        );

        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
            joinResult.records
        );
        // Simulate a successful write to the log.
        joinResult.appendFuture.complete(null);
        assertTrue(joinResult.joinFuture.isDone());

        String leader = supportSkippingAssignment ?
            joinResult.joinFuture.get().memberId() : rebalanceResult.leaderId;

        List<JoinGroupResponseMember> members = supportSkippingAssignment ?
            toJoinResponseMembers(group) : List.of();

        JoinGroupResponseData expectedJoinResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId)
            .setMemberId(joinResult.joinFuture.get().memberId())
            .setLeader(leader)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(supportSkippingAssignment)
            .setMembers(members);

        checkJoinGroupResponse(
            expectedJoinResponse,
            joinResult.joinFuture.get(),
            group,
            STABLE,
            supportSkippingAssignment ? Set.of("leader-instance-id", "follower-instance-id") : Set.of()
        );

        GroupMetadataManagerTestContext.JoinResult oldLeaderJoinResult = context.sendClassicGroupJoin(
            joinRequest.setMemberId(rebalanceResult.leaderId),
            true,
            supportSkippingAssignment
        );

        assertTrue(oldLeaderJoinResult.records.isEmpty());
        assertTrue(oldLeaderJoinResult.joinFuture.isDone());
        assertEquals(Errors.FENCED_INSTANCE_ID.code(), oldLeaderJoinResult.joinFuture.get().errorCode());

        // Old leader will get fenced.
        SyncGroupRequestData oldLeaderSyncRequest = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withGenerationId(rebalanceResult.generationId)
            .withMemberId(rebalanceResult.leaderId)
            .build();

        GroupMetadataManagerTestContext.SyncResult oldLeaderSyncResult = context.sendClassicGroupSync(oldLeaderSyncRequest);

        assertTrue(oldLeaderSyncResult.records.isEmpty());
        assertTrue(oldLeaderSyncResult.syncFuture.isDone());
        assertEquals(Errors.FENCED_INSTANCE_ID.code(), oldLeaderSyncResult.syncFuture.get().errorCode());

        // Calling sync on old leader id will fail because that leader id is no longer valid and replaced.
        SyncGroupRequestData newLeaderSyncRequest = oldLeaderSyncRequest.setGroupInstanceId(null);
        GroupMetadataManagerTestContext.SyncResult newLeaderSyncResult = context.sendClassicGroupSync(newLeaderSyncRequest);

        assertTrue(newLeaderSyncResult.records.isEmpty());
        assertTrue(newLeaderSyncResult.syncFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), newLeaderSyncResult.syncFuture.get().errorCode());
    }

    @Test
    public void testStaticMemberRejoinWithLeaderIdAndKnownMemberId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // Known static leader rejoin will trigger rebalance.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(rebalanceResult.leaderId)
            .withDefaultProtocolTypeAndProtocols()
            .withRebalanceTimeoutMs(10000)
            .build();

        JoinGroupResponseData joinResponse = context.joinClassicGroupAndCompleteJoin(request, true, true, 10000);

        // Follower's heartbeat expires as the leader rejoins.
        assertFalse(group.hasMember(rebalanceResult.followerId));

        JoinGroupResponseData expectedResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(rebalanceResult.leaderId)
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setMembers(toJoinResponseMembers(group));

        checkJoinGroupResponse(
            expectedResponse,
            joinResponse,
            group,
            COMPLETING_REBALANCE,
            Set.of("leader-instance-id")
        );
    }

    @Test
    public void testStaticMemberRejoinWithLeaderIdAndUnexpectedDeadGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);
        group.transitionTo(DEAD);

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(rebalanceResult.leaderId)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request, true, true);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.COORDINATOR_NOT_AVAILABLE.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testStaticMemberRejoinWithLeaderIdAndUnexpectedEmptyGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        group.transitionTo(PREPARING_REBALANCE);
        group.transitionTo(EMPTY);

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(rebalanceResult.leaderId)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request, true, true);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testStaticMemberRejoinWithFollowerIdAndChangeOfProtocol() throws Exception {
        int rebalanceTimeoutMs = 10000;
        int sessionTimeoutMs = 15000;
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id",
            rebalanceTimeoutMs,
            sessionTimeoutMs
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // A static follower rejoin with changed protocol will trigger rebalance.
        JoinGroupRequestProtocolCollection protocols = GroupMetadataManagerTestContext.toProtocols("roundrobin");
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("follower-instance-id")
            .withMemberId(rebalanceResult.followerId)
            .withProtocols(protocols)
            .withRebalanceTimeoutMs(rebalanceTimeoutMs)
            .withSessionTimeoutMs(sessionTimeoutMs)
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertFalse(joinResult.joinFuture.isDone());

        // Old leader hasn't joined in the meantime, triggering a re-election.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(rebalanceTimeoutMs));

        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertTrue(group.hasStaticMember("leader-instance-id"));
        assertTrue(group.isLeader(rebalanceResult.followerId));

        JoinGroupResponseData expectedResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(rebalanceResult.followerId)
            .setLeader(rebalanceResult.followerId)
            .setProtocolName("roundrobin")
            .setProtocolType("consumer")
            .setMembers(toJoinResponseMembers(group));

        checkJoinGroupResponse(
            expectedResponse,
            joinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of("leader-instance-id", "follower-instance-id")
        );
    }

    @Test
    public void testStaticMemberRejoinWithUnknownMemberIdAndChangeOfProtocolWithSelectedProtocolChanged() throws Exception {

        int rebalanceTimeoutMs = 10000;
        int sessionTimeoutMs = 15000;
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id",
            rebalanceTimeoutMs,
            sessionTimeoutMs
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        assertNotEquals("roundrobin", group.selectProtocol());

        // A static follower rejoin with changed protocol will trigger rebalance.
        JoinGroupRequestProtocolCollection protocols = GroupMetadataManagerTestContext.toProtocols("roundrobin");

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("follower-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocols(protocols)
            .withRebalanceTimeoutMs(rebalanceTimeoutMs)
            .withSessionTimeoutMs(sessionTimeoutMs)
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);
        assertTrue(joinResult.records.isEmpty());
        assertFalse(joinResult.joinFuture.isDone());
        assertEquals("roundrobin", group.selectProtocol());

        // Old leader hasn't joined in the meantime, triggering a re-election.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(rebalanceTimeoutMs));
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertTrue(group.hasStaticMember("leader-instance-id"));
        assertTrue(group.isLeader(joinResult.joinFuture.get().memberId()));
        assertNotEquals(rebalanceResult.followerId, joinResult.joinFuture.get().memberId());

        JoinGroupResponseData expectedResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(joinResult.joinFuture.get().memberId())
            .setLeader(joinResult.joinFuture.get().memberId())
            .setProtocolName("roundrobin")
            .setProtocolType("consumer")
            .setMembers(toJoinResponseMembers(group));

        checkJoinGroupResponse(
            expectedResponse,
            joinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of("leader-instance-id", "follower-instance-id")
        );
    }

    @Test
    public void testStaticMemberRejoinWithUnknownMemberIdAndChangeOfProtocolWhileSelectProtocolUnchangedPersistenceFailure() throws Exception {

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        JoinGroupRequestProtocolCollection protocols = GroupMetadataManagerTestContext.toProtocols(group.selectProtocol());

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("follower-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocols(protocols)
            .build();

        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(
            request,
            true,
            true
        );

        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
            followerJoinResult.records
        );
        // Simulate a failed write to the log.
        followerJoinResult.appendFuture.completeExceptionally(Errors.MESSAGE_TOO_LARGE.exception());
        assertTrue(followerJoinResult.joinFuture.isDone());

        JoinGroupResponseData expectedResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.UNKNOWN_SERVER_ERROR.code())
            .setGenerationId(rebalanceResult.generationId)
            .setMemberId(followerJoinResult.joinFuture.get().memberId())
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(List.of());

        checkJoinGroupResponse(
            expectedResponse,
            followerJoinResult.joinFuture.get(),
            group,
            STABLE,
            Set.of()
        );

        // Join with old member id will not fail because the member id is not updated because of persistence failure
        assertNotEquals(rebalanceResult.followerId, followerJoinResult.joinFuture.get().memberId());
        followerJoinResult = context.sendClassicGroupJoin(request.setMemberId(rebalanceResult.followerId));

        assertTrue(followerJoinResult.records.isEmpty());

        // Join with leader and complete join phase.
        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(
            request.setGroupInstanceId("leader-instance-id")
                .setMemberId(rebalanceResult.leaderId)
        );

        assertTrue(leaderJoinResult.records.isEmpty());
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(followerJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), leaderJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), followerJoinResult.joinFuture.get().errorCode());

        // Sync with leader and receive assignment.
        SyncGroupRequestData syncRequest = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(rebalanceResult.leaderId)
            .withGenerationId(rebalanceResult.generationId + 1)
            .build();

        GroupMetadataManagerTestContext.SyncResult leaderSyncResult = context.sendClassicGroupSync(syncRequest);

        // Simulate a successful write to the log. This will update the group with the new (empty) assignment.
        leaderSyncResult.appendFuture.complete(null);

        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
            leaderSyncResult.records
        );

        assertTrue(leaderSyncResult.syncFuture.isDone());
        assertTrue(group.isInState(STABLE));
        assertEquals(Errors.NONE.code(), leaderSyncResult.syncFuture.get().errorCode());

        // Sync with old member id will also not fail as the member id is not updated due to persistence failure
        GroupMetadataManagerTestContext.SyncResult oldMemberSyncResult = context.sendClassicGroupSync(
            syncRequest
                .setGroupInstanceId("follower-instance-id")
                .setMemberId(rebalanceResult.followerId)
        );
        assertTrue(oldMemberSyncResult.records.isEmpty());
        assertTrue(oldMemberSyncResult.syncFuture.isDone());
        assertTrue(group.isInState(STABLE));
        assertEquals(Errors.NONE.code(), oldMemberSyncResult.syncFuture.get().errorCode());
    }

    @Test
    public void testStaticMemberRejoinWithUnknownMemberIdAndChangeOfProtocolWhileSelectProtocolUnchanged() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // A static follower rejoin with protocol changing to leader protocol subset won't trigger rebalance if updated
        // group's selectProtocol remain unchanged.
        JoinGroupRequestProtocolCollection protocols = GroupMetadataManagerTestContext.toProtocols(group.selectProtocol());

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("follower-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocols(protocols)
            .build();

        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(
            request,
            true,
            true
        );

        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
            followerJoinResult.records
        );

        // Simulate a successful write to the log.
        followerJoinResult.appendFuture.complete(null);
        assertTrue(followerJoinResult.joinFuture.isDone());

        JoinGroupResponseData expectedResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId)
            .setMemberId(followerJoinResult.joinFuture.get().memberId())
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(List.of());

        checkJoinGroupResponse(
            expectedResponse,
            followerJoinResult.joinFuture.get(),
            group,
            STABLE,
            Set.of()
        );

        // Join with old member id will fail because the member id is updated
        String newFollowerId = followerJoinResult.joinFuture.get().memberId();
        assertNotEquals(rebalanceResult.followerId, newFollowerId);
        followerJoinResult = context.sendClassicGroupJoin(request.setMemberId(rebalanceResult.followerId));

        assertTrue(followerJoinResult.records.isEmpty());
        assertEquals(Errors.FENCED_INSTANCE_ID.code(), followerJoinResult.joinFuture.get().errorCode());

        // Sync with old member id will fail because the member id is updated
        SyncGroupRequestData syncRequest = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("follower-instance-id")
            .withGenerationId(rebalanceResult.generationId)
            .withMemberId(rebalanceResult.followerId)
            .withAssignment(List.of())
            .build();

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(syncRequest);

        assertTrue(syncResult.records.isEmpty());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.FENCED_INSTANCE_ID.code(), syncResult.syncFuture.get().errorCode());

        // Sync with new member id succeeds
        syncResult = context.sendClassicGroupSync(syncRequest.setMemberId(newFollowerId));

        assertTrue(syncResult.records.isEmpty());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertEquals(rebalanceResult.followerAssignment, syncResult.syncFuture.get().assignment());
    }

    @Test
    public void testStaticMemberRejoinWithKnownLeaderIdToTriggerRebalanceAndFollowerWithChangeOfProtocol()
        throws Exception {

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // A static leader rejoin with known member id will trigger rebalance.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(rebalanceResult.leaderId)
            .withProtocolSuperset()
            .withRebalanceTimeoutMs(10000)
            .withSessionTimeoutMs(5000)
            .build();

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(
            request,
            true,
            true
        );

        assertTrue(leaderJoinResult.records.isEmpty());
        assertFalse(leaderJoinResult.joinFuture.isDone());

        // Rebalance completes immediately after follower rejoins.
        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(
            request.setGroupInstanceId("follower-instance-id")
                .setMemberId(rebalanceResult.followerId),
            true,
            true
        );

        assertTrue(followerJoinResult.records.isEmpty());
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(followerJoinResult.joinFuture.isDone());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(2, group.generationId());

        // Leader should get the same assignment as last round.
        JoinGroupResponseData expectedLeaderResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1) // The group has promoted to the new generation.
            .setMemberId(leaderJoinResult.joinFuture.get().memberId())
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(toJoinResponseMembers(group));

        checkJoinGroupResponse(
            expectedLeaderResponse,
            leaderJoinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of("leader-instance-id", "follower-instance-id")
        );

        JoinGroupResponseData expectedFollowerResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1) // The group has promoted to the new generation.
            .setMemberId(followerJoinResult.joinFuture.get().memberId())
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(List.of());

        checkJoinGroupResponse(
            expectedFollowerResponse,
            followerJoinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of()
        );

        // The follower protocol changed from protocolSuperset to general protocols.
        JoinGroupRequestProtocolCollection protocols = GroupMetadataManagerTestContext.toProtocols("range");

        followerJoinResult = context.sendClassicGroupJoin(
            request.setGroupInstanceId("follower-instance-id")
                .setMemberId(rebalanceResult.followerId)
                .setProtocols(protocols),
            true,
            true
        );

        assertTrue(followerJoinResult.records.isEmpty());
        assertFalse(followerJoinResult.joinFuture.isDone());
        // The group will transition to PreparingRebalance due to protocol change from follower.
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Advance clock by session timeout to kick leader out and complete join phase.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(5000);
        // Both leader and follower heartbeat timers may expire. However, the follower heartbeat expiration
        // will not kick the follower out because it is awaiting a join response.
        assertTrue(timeouts.size() <= 2);
        assertTrue(followerJoinResult.joinFuture.isDone());

        String newFollowerId = followerJoinResult.joinFuture.get().memberId();
        expectedFollowerResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 2) // The group has promoted to the new generation.
            .setMemberId(newFollowerId)
            .setLeader(newFollowerId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(toJoinResponseMembers(group));

        checkJoinGroupResponse(
            expectedFollowerResponse,
            followerJoinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of("follower-instance-id")
        );
    }

    @Test
    public void testStaticMemberRejoinAsFollowerWithUnknownMemberId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // A static follower rejoin with no protocol change will not trigger rebalance.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("follower-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolSuperset()
            .build();

        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(
            request,
            true,
            true
        );

        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
            followerJoinResult.records
        );
        // Simulate a successful write to log.
        followerJoinResult.appendFuture.complete(null);

        assertTrue(followerJoinResult.joinFuture.isDone());

        // Old leader shouldn't be timed out.
        assertTrue(group.hasStaticMember("leader-instance-id"));

        JoinGroupResponseData expectedFollowerResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId) // The group has not changed.
            .setMemberId(followerJoinResult.joinFuture.get().memberId())
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(List.of());

        checkJoinGroupResponse(
            expectedFollowerResponse,
            followerJoinResult.joinFuture.get(),
            group,
            STABLE,
            Set.of()
        );
        assertNotEquals(rebalanceResult.followerId, followerJoinResult.joinFuture.get().memberId());

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withGroupInstanceId("follower-instance-id")
                .withGenerationId(rebalanceResult.generationId)
                .withMemberId(followerJoinResult.joinFuture.get().memberId())
                .build()
        );

        assertTrue(syncResult.records.isEmpty());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertEquals(rebalanceResult.followerAssignment, syncResult.syncFuture.get().assignment());
    }

    @Test
    public void testStaticMemberRejoinAsFollowerWithKnownMemberIdAndNoProtocolChange() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // A static follower rejoin with no protocol change will not trigger rebalance.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("follower-instance-id")
            .withMemberId(rebalanceResult.followerId)
            .withProtocolSuperset()
            .build();

        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(
            request,
            true,
            true
        );

        // No records to write because no metadata changed.
        assertTrue(followerJoinResult.records.isEmpty());
        assertTrue(followerJoinResult.joinFuture.isDone());

        // Old leader shouldn't be timed out.
        assertTrue(group.hasStaticMember("leader-instance-id"));

        JoinGroupResponseData expectedFollowerResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId) // The group has not changed.
            .setMemberId(rebalanceResult.followerId)
            .setLeader(rebalanceResult.leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(List.of());

        checkJoinGroupResponse(
            expectedFollowerResponse,
            followerJoinResult.joinFuture.get(),
            group,
            STABLE,
            Set.of()
        );
    }

    @Test
    public void testStaticMemberRejoinAsFollowerWithMismatchedInstanceId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(rebalanceResult.followerId)
            .withProtocolSuperset()
            .build();

        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(
            request,
            true,
            true
        );

        assertTrue(followerJoinResult.records.isEmpty());
        assertTrue(followerJoinResult.joinFuture.isDone());
        assertEquals(Errors.FENCED_INSTANCE_ID.code(), followerJoinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testStaticMemberRejoinAsLeaderWithMismatchedInstanceId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("follower-instance-id")
            .withMemberId(rebalanceResult.leaderId)
            .withProtocolSuperset()
            .build();

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(
            request,
            true,
            true
        );

        assertTrue(leaderJoinResult.records.isEmpty());
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertEquals(Errors.FENCED_INSTANCE_ID.code(), leaderJoinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testStaticMemberSyncAsLeaderWithInvalidMemberId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );

        SyncGroupRequestData request = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("follower-instance-id")
            .withMemberId("invalid-member-id")
            .build();

        GroupMetadataManagerTestContext.SyncResult leaderSyncResult = context.sendClassicGroupSync(request);

        assertTrue(leaderSyncResult.records.isEmpty());
        assertTrue(leaderSyncResult.syncFuture.isDone());
        assertEquals(Errors.FENCED_INSTANCE_ID.code(), leaderSyncResult.syncFuture.get().errorCode());
    }

    @Test
    public void testGetDifferentStaticMemberIdAfterEachRejoin() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        String lastMemberId = rebalanceResult.leaderId;
        for (int i = 0; i < 5; i++) {
            JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withGroupInstanceId("leader-instance-id")
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withProtocolSuperset()
                .build();

            GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(
                request,
                true,
                true
            );

            assertEquals(
                List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
                leaderJoinResult.records
            );
            // Simulate a successful write to log.
            leaderJoinResult.appendFuture.complete(null);
            assertTrue(leaderJoinResult.joinFuture.isDone());
            assertEquals(group.staticMemberId("leader-instance-id"), leaderJoinResult.joinFuture.get().memberId());
            assertEquals(Errors.NONE.code(), leaderJoinResult.joinFuture.get().errorCode());
            assertNotEquals(lastMemberId, leaderJoinResult.joinFuture.get().memberId());

            lastMemberId = leaderJoinResult.joinFuture.get().memberId();
        }
    }

    @Test
    public void testStaticMemberJoinWithUnknownInstanceIdAndKnownMemberId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("unknown-instance-id")
            .withMemberId(rebalanceResult.leaderId)
            .withProtocolSuperset()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            request,
            true,
            true
        );

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testStaticMemberReJoinWithIllegalStateAsUnknownMember() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        group.transitionTo(PREPARING_REBALANCE);
        group.transitionTo(EMPTY);

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("follower-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolSuperset()
            .build();

        // Illegal state exception shall trigger since follower id resides in pending member bucket.
        IllegalStateException exception = assertThrows(IllegalStateException.class, () -> context.sendClassicGroupJoin(
            request,
            true,
            true
        ));

        String message = exception.getMessage();
        assertTrue(message.contains(group.groupId()));
        assertTrue(message.contains("follower-instance-id"));
    }

    @Test
    public void testStaticMemberFollowerFailToRejoinBeforeRebalanceTimeout() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // Increase session timeout so that the follower won't be evicted when rebalance timeout is reached.
        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id",
            10000,
            15000
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        String newMemberInstanceId = "new-member-instance-id";
        String leaderId = rebalanceResult.leaderId;

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId(newMemberInstanceId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolSuperset()
            .build();

        GroupMetadataManagerTestContext.JoinResult newMemberJoinResult = context.sendClassicGroupJoin(
            request,
            true,
            true
        );

        assertTrue(newMemberJoinResult.records.isEmpty());
        assertFalse(newMemberJoinResult.joinFuture.isDone());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(
            request
                .setGroupInstanceId("leader-instance-id")
                .setMemberId(leaderId),
            true,
            true
        );

        assertTrue(leaderJoinResult.records.isEmpty());
        assertFalse(leaderJoinResult.joinFuture.isDone());

        // Advance clock by rebalance timeout to complete join phase.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(10000));

        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(newMemberJoinResult.joinFuture.isDone());

        JoinGroupResponseData expectedLeaderResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(leaderId)
            .setLeader(leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(toJoinResponseMembers(group));

        checkJoinGroupResponse(
            expectedLeaderResponse,
            leaderJoinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of("leader-instance-id", "follower-instance-id", newMemberInstanceId)
        );

        JoinGroupResponseData expectedNewMemberResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(newMemberJoinResult.joinFuture.get().memberId())
            .setLeader(leaderId)
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(List.of());

        checkJoinGroupResponse(
            expectedNewMemberResponse,
            newMemberJoinResult.joinFuture.get(),
            group,
            COMPLETING_REBALANCE,
            Set.of()
        );
    }

    @Test
    public void testStaticMemberLeaderFailToRejoinBeforeRebalanceTimeout() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // Increase session timeout so that the leader won't be evicted when rebalance timeout is reached.
        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id",
            10000,
            15000
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        String newMemberInstanceId = "new-member-instance-id";
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId(newMemberInstanceId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolSuperset()
            .build();

        GroupMetadataManagerTestContext.JoinResult newMemberJoinResult = context.sendClassicGroupJoin(
            request,
            true,
            true
        );

        assertTrue(newMemberJoinResult.records.isEmpty());
        assertFalse(newMemberJoinResult.joinFuture.isDone());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        GroupMetadataManagerTestContext.JoinResult oldFollowerJoinResult = context.sendClassicGroupJoin(
            request
                .setGroupInstanceId("follower-instance-id")
                .setMemberId(rebalanceResult.followerId),
            true,
            true
        );

        assertTrue(oldFollowerJoinResult.records.isEmpty());
        assertFalse(oldFollowerJoinResult.joinFuture.isDone());

        // Advance clock by rebalance timeout to complete join phase.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(10000));

        assertTrue(oldFollowerJoinResult.joinFuture.isDone());
        assertTrue(newMemberJoinResult.joinFuture.isDone());

        JoinGroupResponseData oldFollowerJoinResponse = oldFollowerJoinResult.joinFuture.get();
        JoinGroupResponseData newMemberJoinResponse = newMemberJoinResult.joinFuture.get();

        JoinGroupResponseData newLeaderResponse = oldFollowerJoinResponse.leader()
            .equals(oldFollowerJoinResponse.memberId()) ? oldFollowerJoinResponse : newMemberJoinResponse;

        JoinGroupResponseData newFollowerResponse = oldFollowerJoinResponse.leader()
            .equals(oldFollowerJoinResponse.memberId()) ? newMemberJoinResponse : oldFollowerJoinResponse;

        JoinGroupResponseData expectedLeaderResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(newLeaderResponse.memberId())
            .setLeader(newLeaderResponse.memberId())
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(toJoinResponseMembers(group));

        checkJoinGroupResponse(
            expectedLeaderResponse,
            newLeaderResponse,
            group,
            COMPLETING_REBALANCE,
            Set.of("leader-instance-id", "follower-instance-id", newMemberInstanceId)
        );

        JoinGroupResponseData expectedNewMemberResponse = new JoinGroupResponseData()
            .setErrorCode(Errors.NONE.code())
            .setGenerationId(rebalanceResult.generationId + 1)
            .setMemberId(newFollowerResponse.memberId())
            .setLeader(newLeaderResponse.memberId())
            .setProtocolName("range")
            .setProtocolType("consumer")
            .setSkipAssignment(false)
            .setMembers(List.of());

        checkJoinGroupResponse(
            expectedNewMemberResponse,
            newFollowerResponse,
            group,
            COMPLETING_REBALANCE,
            Set.of()
        );
    }

    @Test
    public void testSyncGroupReturnsAnErrorWhenProtocolTypeIsInconsistent() throws Exception {
        testSyncGroupProtocolTypeAndNameWith(
            Optional.of("protocolType"),
            Optional.empty(),
            Errors.INCONSISTENT_GROUP_PROTOCOL,
            Optional.empty(),
            Optional.empty()
        );
    }

    @Test
    public void testSyncGroupReturnsAnErrorWhenProtocolNameIsInconsistent() throws Exception {
        testSyncGroupProtocolTypeAndNameWith(
            Optional.empty(),
            Optional.of("protocolName"),
            Errors.INCONSISTENT_GROUP_PROTOCOL,
            Optional.empty(),
            Optional.empty()
        );
    }

    @Test
    public void testSyncGroupSucceedWhenProtocolTypeAndNameAreNotProvided() throws Exception {
        testSyncGroupProtocolTypeAndNameWith(
            Optional.empty(),
            Optional.empty(),
            Errors.NONE,
            Optional.of("consumer"),
            Optional.of("range")
        );
    }

    @Test
    public void testSyncGroupSucceedWhenProtocolTypeAndNameAreConsistent() throws Exception {
        testSyncGroupProtocolTypeAndNameWith(
            Optional.of("consumer"),
            Optional.of("range"),
            Errors.NONE,
            Optional.of("consumer"),
            Optional.of("range")
        );
    }

    private void testSyncGroupProtocolTypeAndNameWith(
        Optional<String> protocolType,
        Optional<String> protocolName,
        Errors expectedError,
        Optional<String> expectedProtocolType,
        Optional<String> expectedProtocolName
    ) throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        // JoinGroup(leader) with the Protocol Type of the group
        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolSuperset()
            .build();

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(joinRequest);

        assertTrue(leaderJoinResult.records.isEmpty());
        assertFalse(leaderJoinResult.joinFuture.isDone());

        // JoinGroup(follower) with the Protocol Type of the group
        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(joinRequest.setGroupInstanceId("follower-instance-id"));

        assertTrue(followerJoinResult.records.isEmpty());
        assertFalse(followerJoinResult.joinFuture.isDone());

        // Advance clock by rebalance timeout to complete join phase.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(10000));
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(followerJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), leaderJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), followerJoinResult.joinFuture.get().errorCode());

        String leaderId = leaderJoinResult.joinFuture.get().memberId();
        String followerId = followerJoinResult.joinFuture.get().memberId();
        int generationId = leaderJoinResult.joinFuture.get().generationId();

        // SyncGroup with the provided Protocol Type and Name
        List<SyncGroupRequestAssignment> assignment = new ArrayList<>();
        assignment.add(new SyncGroupRequestAssignment().setMemberId(leaderId));
        SyncGroupRequestData syncRequest = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(leaderId)
            .withProtocolType(protocolType.orElse(null))
            .withProtocolName(protocolName.orElse(null))
            .withGenerationId(generationId)
            .withAssignment(assignment)
            .build();

        GroupMetadataManagerTestContext.SyncResult leaderSyncResult = context.sendClassicGroupSync(syncRequest);
        // Simulate a successful write to the log.
        leaderSyncResult.appendFuture.complete(null);

        assertTrue(leaderSyncResult.syncFuture.isDone());
        assertEquals(expectedError.code(), leaderSyncResult.syncFuture.get().errorCode());
        assertEquals(expectedProtocolType.orElse(null), leaderSyncResult.syncFuture.get().protocolType());
        assertEquals(expectedProtocolName.orElse(null), leaderSyncResult.syncFuture.get().protocolName());

        GroupMetadataManagerTestContext.SyncResult followerSyncResult = context.sendClassicGroupSync(syncRequest.setMemberId(followerId));

        assertTrue(followerSyncResult.records.isEmpty());
        assertTrue(followerSyncResult.syncFuture.isDone());
        assertEquals(expectedError.code(), followerSyncResult.syncFuture.get().errorCode());
        assertEquals(expectedProtocolType.orElse(null), followerSyncResult.syncFuture.get().protocolType());
        assertEquals(expectedProtocolName.orElse(null), followerSyncResult.syncFuture.get().protocolName());
    }

    @Test
    public void testSyncGroupFromUnknownGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // SyncGroup with the provided Protocol Type and Name
        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId("member-id")
                .withGenerationId(1)
                .build()
        );

        assertTrue(syncResult.records.isEmpty());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), syncResult.syncFuture.get().errorCode());
    }

    @Test
    public void testSyncGroupFromUnknownMember() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupResponseData joinResponse = context.joinClassicGroupAndCompleteJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withGroupInstanceId("leader-instance-id")
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withDefaultProtocolTypeAndProtocols()
                .build(),
            true,
            true
        );

        String memberId = joinResponse.memberId();
        int generationId = joinResponse.generationId();

        List<SyncGroupRequestAssignment> assignment = new ArrayList<>();
        assignment.add(new SyncGroupRequestAssignment().setMemberId(memberId));
        SyncGroupRequestData syncRequest = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(memberId)
            .withGenerationId(generationId)
            .withAssignment(assignment)
            .build();

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(syncRequest);

        // Simulate a successful write to log.
        syncResult.appendFuture.complete(null);

        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertEquals(assignment.get(0).assignment(), syncResult.syncFuture.get().assignment());

        // Sync with unknown member.
        syncResult = context.sendClassicGroupSync(syncRequest.setMemberId("unknown-member-id"));

        assertTrue(syncResult.records.isEmpty());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), syncResult.syncFuture.get().errorCode());
        assertEquals(ClassicGroupMember.EMPTY_ASSIGNMENT, syncResult.syncFuture.get().assignment());
    }

    @Test
    public void testSyncGroupFromIllegalGeneration() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        JoinGroupResponseData joinResponse = context.joinClassicGroupAndCompleteJoin(joinRequest, true, true);

        String memberId = joinResponse.memberId();
        int generationId = joinResponse.generationId();

        // Send the sync group with an invalid generation
        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(memberId)
                .withGenerationId(generationId + 1)
                .build()
        );

        assertTrue(syncResult.records.isEmpty());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.ILLEGAL_GENERATION.code(), syncResult.syncFuture.get().errorCode());
    }

    @Test
    public void testSyncGroupAsLeaderAppendFailureTransformsError() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        JoinGroupResponseData joinResponse = context.joinClassicGroupAndCompleteJoin(joinRequest, true, true);

        // Send the sync group with an invalid generation
        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(joinResponse.memberId())
                .withGenerationId(1)
                .build()
        );

        assertFalse(syncResult.syncFuture.isDone());

        // Simulate a failed write to the log.
        syncResult.appendFuture.completeExceptionally(new NotLeaderOrFollowerException());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NOT_COORDINATOR.code(), syncResult.syncFuture.get().errorCode());
    }

    @Test
    public void testJoinGroupFromUnchangedFollowerDoesNotRebalance() throws Exception {
        // To get a group of two members:
        // 1. join and sync with a single member (because we can't immediately join with two members)
        // 2. join and sync with the first member and a new member
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");

        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(joinRequest);

        assertTrue(followerJoinResult.records.isEmpty());
        assertFalse(followerJoinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(joinRequest.setMemberId(leaderJoinResponse.memberId()));

        assertTrue(leaderJoinResult.records.isEmpty());
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(followerJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), leaderJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), followerJoinResult.joinFuture.get().errorCode());
        assertEquals(leaderJoinResult.joinFuture.get().generationId(), followerJoinResult.joinFuture.get().generationId());
        assertEquals(leaderJoinResponse.memberId(), leaderJoinResult.joinFuture.get().leader());
        assertEquals(leaderJoinResponse.memberId(), followerJoinResult.joinFuture.get().leader());

        int nextGenerationId = leaderJoinResult.joinFuture.get().generationId();
        String followerId = followerJoinResult.joinFuture.get().memberId();

        // This shouldn't cause a rebalance since protocol information hasn't changed
        followerJoinResult = context.sendClassicGroupJoin(joinRequest.setMemberId(followerId));

        assertTrue(followerJoinResult.records.isEmpty());
        assertTrue(followerJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), followerJoinResult.joinFuture.get().errorCode());
        assertEquals(nextGenerationId, followerJoinResult.joinFuture.get().generationId());
    }

    @Test
    public void testLeaderFailureInSyncGroup() throws Exception {
        // To get a group of two members:
        // 1. join and sync with a single member (because we can't immediately join with two members)
        // 2. join and sync with the first member and a new member

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withRebalanceTimeoutMs(10000)
            .withSessionTimeoutMs(5000)
            .build();

        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(joinRequest);

        assertTrue(followerJoinResult.records.isEmpty());
        assertFalse(followerJoinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(joinRequest.setMemberId(leaderJoinResponse.memberId()));

        assertTrue(leaderJoinResult.records.isEmpty());
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(followerJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), leaderJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), followerJoinResult.joinFuture.get().errorCode());
        assertEquals(leaderJoinResult.joinFuture.get().generationId(), followerJoinResult.joinFuture.get().generationId());
        assertEquals(leaderJoinResponse.memberId(), leaderJoinResult.joinFuture.get().leader());
        assertEquals(leaderJoinResponse.memberId(), followerJoinResult.joinFuture.get().leader());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        int nextGenerationId = leaderJoinResult.joinFuture.get().generationId();
        String followerId = followerJoinResult.joinFuture.get().memberId();

        // With no leader SyncGroup, the follower's sync request should fail with an error indicating
        // that it should rejoin
        GroupMetadataManagerTestContext.SyncResult followerSyncResult = context.sendClassicGroupSync(new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(followerId)
            .withGenerationId(nextGenerationId)
            .build());

        assertTrue(followerSyncResult.records.isEmpty());
        assertFalse(followerSyncResult.syncFuture.isDone());

        // Advance clock by session timeout to expire leader heartbeat and prepare rebalance.
        // This should complete follower's sync response. The follower's heartbeat expiration will not kick
        // the follower out because it is awaiting sync.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(10000);
        assertTrue(timeouts.size() <= 2);
        timeouts.forEach(timeout -> assertTrue(timeout.result().records().isEmpty()));

        assertTrue(followerSyncResult.syncFuture.isDone());
        assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), followerSyncResult.syncFuture.get().errorCode());
        assertEquals(1, group.numMembers());
        assertTrue(group.hasMember(followerId));
        assertTrue(group.isInState(PREPARING_REBALANCE));
    }

    @Test
    public void testSyncGroupFollowerAfterLeader() throws Exception {
        // To get a group of two members:
        // 1. join and sync with a single member (because we can't immediately join with two members)
        // 2. join and sync with the first member and a new member
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withRebalanceTimeoutMs(10000)
            .withSessionTimeoutMs(5000)
            .build();

        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(joinRequest.setMemberId(UNKNOWN_MEMBER_ID));

        assertTrue(followerJoinResult.records.isEmpty());
        assertFalse(followerJoinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(joinRequest.setMemberId(leaderJoinResponse.memberId()));

        assertTrue(leaderJoinResult.records.isEmpty());
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(followerJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), leaderJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), followerJoinResult.joinFuture.get().errorCode());
        assertEquals(leaderJoinResult.joinFuture.get().generationId(), followerJoinResult.joinFuture.get().generationId());
        assertEquals(leaderJoinResponse.memberId(), leaderJoinResult.joinFuture.get().leader());
        assertEquals(leaderJoinResponse.memberId(), followerJoinResult.joinFuture.get().leader());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        int nextGenerationId = leaderJoinResult.joinFuture.get().generationId();
        String followerId = followerJoinResult.joinFuture.get().memberId();
        byte[] leaderAssignment = new byte[]{0};
        byte[] followerAssignment = new byte[]{1};

        // Sync group with leader to get new assignment.
        List<SyncGroupRequestAssignment> assignment = new ArrayList<>();
        assignment.add(new SyncGroupRequestAssignment()
            .setMemberId(leaderJoinResponse.memberId())
            .setAssignment(leaderAssignment)
        );
        assignment.add(new SyncGroupRequestAssignment()
            .setMemberId(followerId)
            .setAssignment(followerAssignment)
        );

        SyncGroupRequestData syncRequest = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(leaderJoinResponse.memberId())
            .withGenerationId(leaderJoinResponse.generationId())
            .withAssignment(assignment)
            .build();

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            syncRequest.setGenerationId(nextGenerationId)
        );

        // Simulate a successful write to log. This will update the group's assignment with the new assignment.
        syncResult.appendFuture.complete(null);

        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertEquals(leaderAssignment, syncResult.syncFuture.get().assignment());

        // Sync group with follower to get new assignment.
        GroupMetadataManagerTestContext.SyncResult followerSyncResult = context.sendClassicGroupSync(
            syncRequest
                .setMemberId(followerId)
                .setGenerationId(nextGenerationId)
        );

        assertTrue(followerSyncResult.records.isEmpty());
        assertTrue(followerSyncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), followerSyncResult.syncFuture.get().errorCode());
        assertEquals(followerAssignment, followerSyncResult.syncFuture.get().assignment());
        assertTrue(group.isInState(STABLE));
    }

    @Test
    public void testSyncGroupLeaderAfterFollower() throws Exception {
        // To get a group of two members:
        // 1. join and sync with a single member (because we can't immediately join with two members)
        // 2. join and sync with the first member and a new member

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withRebalanceTimeoutMs(10000)
            .withSessionTimeoutMs(5000)
            .build();

        GroupMetadataManagerTestContext.JoinResult followerJoinResult = context.sendClassicGroupJoin(joinRequest);

        assertTrue(followerJoinResult.records.isEmpty());
        assertFalse(followerJoinResult.joinFuture.isDone());

        GroupMetadataManagerTestContext.JoinResult leaderJoinResult = context.sendClassicGroupJoin(joinRequest.setMemberId(leaderJoinResponse.memberId()));

        assertTrue(leaderJoinResult.records.isEmpty());
        assertTrue(leaderJoinResult.joinFuture.isDone());
        assertTrue(followerJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), leaderJoinResult.joinFuture.get().errorCode());
        assertEquals(Errors.NONE.code(), followerJoinResult.joinFuture.get().errorCode());
        assertEquals(leaderJoinResult.joinFuture.get().generationId(), followerJoinResult.joinFuture.get().generationId());
        assertEquals(leaderJoinResponse.memberId(), leaderJoinResult.joinFuture.get().leader());
        assertEquals(leaderJoinResponse.memberId(), followerJoinResult.joinFuture.get().leader());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        int nextGenerationId = leaderJoinResult.joinFuture.get().generationId();
        String followerId = followerJoinResult.joinFuture.get().memberId();
        byte[] leaderAssignment = new byte[]{0};
        byte[] followerAssignment = new byte[]{1};

        // Sync group with follower to get new assignment.
        SyncGroupRequestData syncRequest = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(leaderJoinResponse.memberId())
            .withGenerationId(leaderJoinResponse.generationId())
            .build();

        GroupMetadataManagerTestContext.SyncResult followerSyncResult = context.sendClassicGroupSync(
            syncRequest
                .setMemberId(followerId)
                .setGenerationId(nextGenerationId)
        );

        assertTrue(followerSyncResult.records.isEmpty());
        assertFalse(followerSyncResult.syncFuture.isDone());

        // Sync group with leader to get new assignment.
        List<SyncGroupRequestAssignment> assignment = new ArrayList<>();
        assignment.add(new SyncGroupRequestAssignment()
            .setMemberId(leaderJoinResponse.memberId())
            .setAssignment(leaderAssignment)
        );
        assignment.add(new SyncGroupRequestAssignment()
            .setMemberId(followerId)
            .setAssignment(followerAssignment)
        );

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            syncRequest
                .setMemberId(leaderJoinResponse.memberId())
                .setGenerationId(nextGenerationId)
                .setAssignments(assignment)
        );

        // Simulate a successful write to log. This will update the group assignment with the new assignment.
        syncResult.appendFuture.complete(null);

        Map<String, byte[]> updatedAssignment = assignment.stream().collect(Collectors.toMap(
            SyncGroupRequestAssignment::memberId, SyncGroupRequestAssignment::assignment
        ));

        assertEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, updatedAssignment)),
            syncResult.records
        );

        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertEquals(leaderAssignment, syncResult.syncFuture.get().assignment());

        // Follower sync group should also be completed.
        assertEquals(Errors.NONE.code(), followerSyncResult.syncFuture.get().errorCode());
        assertEquals(followerAssignment, followerSyncResult.syncFuture.get().assignment());
        assertTrue(group.isInState(STABLE));
    }

    @Test
    public void testJoinGroupFromUnchangedLeaderShouldRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");

        // Join group from the leader should force the group to rebalance, which allows the
        // leader to push new assignment when local metadata changes
        GroupMetadataManagerTestContext.JoinResult leaderRejoinResult = context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(leaderJoinResponse.memberId())
                .withDefaultProtocolTypeAndProtocols()
                .build()
        );

        assertTrue(leaderRejoinResult.records.isEmpty());
        assertTrue(leaderRejoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), leaderRejoinResult.joinFuture.get().errorCode());
        assertEquals(leaderJoinResponse.generationId() + 1, leaderRejoinResult.joinFuture.get().generationId());
    }

    @Test
    public void testJoinGroupCompletionWhenPendingMemberJoins() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        // Set up a group in with a pending member. The test checks if the pending member joining
        // completes the rebalancing operation
        JoinGroupResponseData pendingMemberResponse = context.setupGroupWithPendingMember(group).pendingMemberResponse;

        // Compete join group for the pending member
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(pendingMemberResponse.memberId())
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(3, group.allMembers().size());
        assertEquals(0, group.numPendingJoinMembers());
    }

    @Test
    public void testJoinGroupCompletionWhenPendingMemberTimesOut() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        // Set up a group in with a pending member. The test checks if the timeout of the pending member will
        // cause the group to return to a CompletingRebalance state.
        context.setupGroupWithPendingMember(group);

        // Advancing clock by > 2500 (session timeout for the third member)
        // and < 5000 (for first and second members). This will force the coordinator to attempt join
        // completion on heartbeat expiration (since we are in PendingRebalance stage).
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(3000));
        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(2, group.allMembers().size());
        assertEquals(0, group.numPendingJoinMembers());
    }

    @Test
    public void testGenerationIdIncrementsOnRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(leaderJoinResponse.memberId())
                .withDefaultProtocolTypeAndProtocols()
                .build()
        );

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(2, joinResult.joinFuture.get().generationId());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testStaticMemberHeartbeatLeaderWithInvalidMemberId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withGroupInstanceId("leader-instance-id")
                .withMemberId(rebalanceResult.leaderId)
                .withGenerationId(rebalanceResult.generationId)
                .build()
        );

        assertTrue(syncResult.records.isEmpty());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());

        HeartbeatRequestData heartbeatRequest = new HeartbeatRequestData()
            .setGroupId("group-id")
            .setMemberId(rebalanceResult.leaderId)
            .setGenerationId(rebalanceResult.generationId);

        HeartbeatResponseData validHeartbeatResponse = context.sendClassicGroupHeartbeat(heartbeatRequest).response();
        assertEquals(Errors.NONE.code(), validHeartbeatResponse.errorCode());

        assertThrows(FencedInstanceIdException.class, () -> context.sendClassicGroupHeartbeat(
            heartbeatRequest
                .setGroupInstanceId("leader-instance-id")
                .setMemberId("invalid-member-id")
        ));
    }

    @Test
    public void testHeartbeatUnknownGroup() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        HeartbeatRequestData heartbeatRequest = new HeartbeatRequestData()
            .setGroupId("group-id")
            .setMemberId("member-id")
            .setGenerationId(-1);

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupHeartbeat(heartbeatRequest));
    }

    @Test
    public void testHeartbeatDeadGroup() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        group.transitionTo(DEAD);

        HeartbeatRequestData heartbeatRequest = new HeartbeatRequestData()
            .setGroupId("group-id")
            .setMemberId("member-id")
            .setGenerationId(-1);

        assertThrows(CoordinatorNotAvailableException.class, () -> context.sendClassicGroupHeartbeat(heartbeatRequest));
    }

    @Test
    public void testHeartbeatEmptyGroup() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        group.add(new ClassicGroupMember(
            "member-id",
            Optional.empty(),
            "client-id",
            "client-host",
            10000,
            5000,
            "consumer",
            GroupMetadataManagerTestContext.toProtocols("range")
        ));

        HeartbeatRequestData heartbeatRequest = new HeartbeatRequestData()
            .setGroupId("group-id")
            .setMemberId("member-id")
            .setGenerationId(0);

        HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(heartbeatRequest).response();
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), heartbeatResponse.errorCode());
    }

    @Test
    public void testHeartbeatUnknownMemberExistingGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId("group-id")
                .setMemberId("unknown-member-id")
                .setGenerationId(leaderJoinResponse.generationId())
        ));
    }

    @Test
    public void testHeartbeatDuringPreparingRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(joinRequest, true);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.MEMBER_ID_REQUIRED.code(), joinResult.joinFuture.get().errorCode());

        String memberId = joinResult.joinFuture.get().memberId();

        context.sendClassicGroupJoin(joinRequest.setMemberId(memberId));

        assertTrue(group.isInState(PREPARING_REBALANCE));

        HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId("group-id")
                .setMemberId(memberId)
                .setGenerationId(0)
        ).response();

        assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), heartbeatResponse.errorCode());
    }

    @Test
    public void testHeartbeatDuringCompletingRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupResponseData leaderJoinResponse =
            context.joinClassicGroupAsDynamicMemberAndCompleteJoin(new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withDefaultProtocolTypeAndProtocols()
                .build());

        assertEquals(1, leaderJoinResponse.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId("group-id")
                .setMemberId(leaderJoinResponse.memberId())
                .setGenerationId(leaderJoinResponse.generationId())
        ).response();

        assertEquals(new HeartbeatResponseData(), heartbeatResponse);
    }

    @Test
    public void testHeartbeatIllegalGeneration() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");

        assertThrows(IllegalGenerationException.class, () -> context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId("group-id")
                .setMemberId(leaderJoinResponse.memberId())
                .setGenerationId(leaderJoinResponse.generationId() + 1)
        ));
    }

    @Test
    public void testValidHeartbeat() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");

        HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId("group-id")
                .setMemberId(leaderJoinResponse.memberId())
                .setGenerationId(leaderJoinResponse.generationId())
        ).response();

        assertEquals(Errors.NONE.code(), heartbeatResponse.errorCode());
    }

    @Test
    public void testClassicGroupMemberSessionTimeout() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // Advance clock by session timeout to kick member out.
        context.verifySessionExpiration(group, 5000);

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId("group-id")
                .setMemberId(leaderJoinResponse.memberId())
                .setGenerationId(leaderJoinResponse.generationId())
        ));
    }

    @Test
    public void testClassicGroupMemberHeartbeatMaintainsSession() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");

        // Advance clock by 1/2 of session timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(2500));

        HeartbeatRequestData heartbeatRequest = new HeartbeatRequestData()
            .setGroupId("group-id")
            .setMemberId(leaderJoinResponse.memberId())
            .setGenerationId(leaderJoinResponse.generationId());

        HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(heartbeatRequest).response();
        assertEquals(Errors.NONE.code(), heartbeatResponse.errorCode());

        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(2500));

        heartbeatResponse = context.sendClassicGroupHeartbeat(heartbeatRequest).response();
        assertEquals(Errors.NONE.code(), heartbeatResponse.errorCode());
    }

    @Test
    public void testClassicGroupMemberSessionTimeoutDuringRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // Add a new member. This should trigger a rebalance. The new member has the
        // 'classicGroupNewMemberJoinTimeoutMs` session timeout, so it has a longer expiration than the existing member.
        GroupMetadataManagerTestContext.JoinResult otherJoinResult = context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withDefaultProtocolTypeAndProtocols()
                .withRebalanceTimeoutMs(10000)
                .withSessionTimeoutMs(5000)
                .build()
        );

        assertTrue(otherJoinResult.records.isEmpty());
        assertFalse(otherJoinResult.joinFuture.isDone());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Advance clock by 1/2 of session timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(2500));

        HeartbeatRequestData heartbeatRequest = new HeartbeatRequestData()
            .setGroupId("group-id")
            .setMemberId(leaderJoinResponse.memberId())
            .setGenerationId(leaderJoinResponse.generationId());

        HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(heartbeatRequest).response();
        assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), heartbeatResponse.errorCode());

        // Advance clock by first member's session timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(5000));

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupHeartbeat(heartbeatRequest));

        // Advance clock by remaining rebalance timeout to complete join phase.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(2500));

        assertTrue(otherJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), otherJoinResult.joinFuture.get().errorCode());
        assertEquals(1, group.numMembers());
        assertEquals(2, group.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));
    }

    @Test
    public void testRebalanceCompletesBeforeMemberJoins() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        // Create a group with a single member
        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withRebalanceTimeoutMs(10000)
            .withSessionTimeoutMs(5000)
            .build();

        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAndCompleteJoin(joinRequest, true, true);

        String firstMemberId = leaderJoinResponse.memberId();
        int firstGenerationId = leaderJoinResponse.generationId();

        assertEquals(1, firstGenerationId);
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        SyncGroupRequestData syncRequest = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(firstMemberId)
            .withGenerationId(firstGenerationId)
            .build();

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(syncRequest);

        // Simulate a successful write to the log.
        syncResult.appendFuture.complete(null);

        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertTrue(group.isInState(STABLE));

        // Add a new dynamic member. This should trigger a rebalance. The new member has the
        // 'classicGroupNewMemberJoinTimeoutMs` session timeout, so it has a longer expiration than the existing member.
        GroupMetadataManagerTestContext.JoinResult secondMemberJoinResult = context.sendClassicGroupJoin(
            joinRequest
                .setMemberId(UNKNOWN_MEMBER_ID)
                .setGroupInstanceId(null)
                .setSessionTimeoutMs(2500)
        );

        assertTrue(secondMemberJoinResult.records.isEmpty());
        assertFalse(secondMemberJoinResult.joinFuture.isDone());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Send a couple heartbeats to keep the first member alive while the rebalance finishes.
        HeartbeatRequestData firstMemberHeartbeatRequest = new HeartbeatRequestData()
            .setGroupId("group-id")
            .setMemberId(firstMemberId)
            .setGenerationId(firstGenerationId);

        for (int i = 0; i < 2; i++) {
            GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(2500));
            HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(firstMemberHeartbeatRequest).response();
            assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), heartbeatResponse.errorCode());
        }

        // Advance clock by remaining rebalance timeout to complete join phase.
        // The second member will become the leader. However, as the first member is a static member
        // it will not be kicked out.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(8000));

        assertTrue(secondMemberJoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), secondMemberJoinResult.joinFuture.get().errorCode());
        assertEquals(2, group.numMembers());
        assertEquals(2, group.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        String otherMemberId = secondMemberJoinResult.joinFuture.get().memberId();

        syncResult = context.sendClassicGroupSync(
            syncRequest
                .setGroupInstanceId(null)
                .setMemberId(otherMemberId)
                .setGenerationId(2)
        );

        // Simulate a successful write to the log.
        syncResult.appendFuture.complete(null);

        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertTrue(group.isInState(STABLE));

        // The unjoined (first) static member should be remained in the group before session timeout.
        assertThrows(IllegalGenerationException.class, () -> context.sendClassicGroupHeartbeat(firstMemberHeartbeatRequest));

        // Now session timeout the unjoined (first) member. Still keeping the new member.
        List<Errors> expectedErrors = List.of(Errors.NONE, Errors.NONE, Errors.REBALANCE_IN_PROGRESS);
        for (Errors expectedError : expectedErrors) {
            GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(2000));
            HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(
                firstMemberHeartbeatRequest
                    .setMemberId(otherMemberId)
                    .setGenerationId(2)
            ).response();

            assertEquals(expectedError.code(), heartbeatResponse.errorCode());
        }
        assertEquals(1, group.numMembers());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        GroupMetadataManagerTestContext.JoinResult otherMemberRejoinResult = context.sendClassicGroupJoin(
            joinRequest
                .setMemberId(otherMemberId)
                .setGroupInstanceId(null)
                .setSessionTimeoutMs(2500)
        );

        assertTrue(otherMemberRejoinResult.records.isEmpty());
        assertTrue(otherMemberRejoinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), otherMemberRejoinResult.joinFuture.get().errorCode());
        assertEquals(3, otherMemberRejoinResult.joinFuture.get().generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        GroupMetadataManagerTestContext.SyncResult otherMemberResyncResult = context.sendClassicGroupSync(
            syncRequest
                .setGroupInstanceId(null)
                .setMemberId(otherMemberId)
                .setGenerationId(3)
        );

        // Simulate a successful write to the log.
        otherMemberResyncResult.appendFuture.complete(null);

        assertTrue(otherMemberResyncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), otherMemberResyncResult.syncFuture.get().errorCode());
        assertTrue(group.isInState(STABLE));

        // The joined member should get heart beat response with no error. Let the new member keep
        // heartbeating for a while to verify that no new rebalance is triggered unexpectedly.
        for (int i = 0; i < 20; i++) {
            GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(2000));
            HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(
                firstMemberHeartbeatRequest
                    .setMemberId(otherMemberId)
                    .setGenerationId(3)
            ).response();

            assertEquals(Errors.NONE.code(), heartbeatResponse.errorCode());
        }
    }

    @Test
    public void testSyncGroupEmptyAssignment() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");

        HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId("group-id")
                .setMemberId(leaderJoinResponse.memberId())
                .setGenerationId(leaderJoinResponse.generationId())
        ).response();

        assertEquals(Errors.NONE.code(), heartbeatResponse.errorCode());
    }

    @Test
    public void testSecondMemberPartiallyJoinAndTimeout() throws Exception {
        // Test if the following scenario completes a rebalance correctly: A new member starts a JoinGroup request with
        // an UNKNOWN_MEMBER_ID, attempting to join a stable group. But never initiates the second JoinGroup request with
        // the provided member ID and times out. The test checks if original member remains the sole member in this group,
        // which should remain stable throughout this test.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        // Create a group with a single member
        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withGroupInstanceId("leader-instance-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withRebalanceTimeoutMs(10000)
            .withSessionTimeoutMs(5000)
            .build();

        JoinGroupResponseData leaderJoinResponse = context.joinClassicGroupAndCompleteJoin(joinRequest, true, true);

        String firstMemberId = leaderJoinResponse.memberId();
        int firstGenerationId = leaderJoinResponse.generationId();

        assertEquals(1, firstGenerationId);
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(firstMemberId)
            .withGenerationId(firstGenerationId)
            .build());

        // Simulate a successful write to the log.
        syncResult.appendFuture.complete(null);

        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertTrue(group.isInState(STABLE));

        // Add a new dynamic pending member.
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            joinRequest
                .setMemberId(UNKNOWN_MEMBER_ID)
                .setGroupInstanceId(null)
                .setSessionTimeoutMs(5000),
            true,
            true
        );

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.MEMBER_ID_REQUIRED.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(1, group.numPendingJoinMembers());
        assertTrue(group.isInState(STABLE));

        // Heartbeat from the leader to maintain session while timing out pending member.
        HeartbeatRequestData heartbeatRequest = new HeartbeatRequestData()
            .setGroupId("group-id")
            .setMemberId(firstMemberId)
            .setGenerationId(firstGenerationId);

        for (int i = 0; i < 2; i++) {
            GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(2500));
            HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(heartbeatRequest).response();
            assertEquals(Errors.NONE.code(), heartbeatResponse.errorCode());
        }

        // At this point the second member should have been removed from pending list (session timeout),
        // and the group should be in Stable state with only the first member in it.
        assertEquals(1, group.numMembers());
        assertTrue(group.hasMember(firstMemberId));
        assertEquals(1, group.generationId());
        assertTrue(group.isInState(STABLE));
    }

    @Test
    public void testRebalanceTimesOutWhenSyncRequestIsNotReceived() throws Exception {
        // This test case ensure that the pending sync expiration does kick out all members
        // if they don't send sync requests before the rebalance timeout. The
        // group is in the Empty state in this case.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        int rebalanceTimeoutMs = 5000;
        int sessionTimeoutMs = 5000;
        List<JoinGroupResponseData> joinResponses = context.joinWithNMembers("group-id", 3, rebalanceTimeoutMs, sessionTimeoutMs);
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // Advance clock by 1/2 rebalance timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(rebalanceTimeoutMs / 2));

        // Heartbeats to ensure that heartbeating does not interfere with the
        // delayed sync operation.
        joinResponses.forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.NONE));

        // Advance clock by 1/2 rebalance timeout to expire the pending sync. Members should be removed.
        // The group becomes empty, generating an empty group metadata record.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(rebalanceTimeoutMs / 2);
        assertEquals(1, timeouts.size());
        ExpiredTimeout<CoordinatorRecord> timeout = timeouts.get(0);
        assertEquals(classicGroupSyncKey("group-id"), timeout.key());
        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
            timeout.result().records()
        );

        // Simulate a successful write to the log.
        timeout.result().appendFuture().complete(null);

        // Heartbeats fail because none of the members have sent the sync request
        joinResponses.forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.UNKNOWN_MEMBER_ID));
        assertTrue(group.isInState(EMPTY));
    }

    @Test
    public void testRebalanceTimesOutWhenSyncRequestIsNotReceivedFromFollowers() throws Exception {
        // This test case ensure that the pending sync expiration does kick out the followers
        // if they don't send a sync request before the rebalance timeout. The
        // group is in the PreparingRebalance state in this case.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        int rebalanceTimeoutMs = 5000;
        int sessionTimeoutMs = 5000;
        List<JoinGroupResponseData> joinResponses = context.joinWithNMembers("group-id", 3, rebalanceTimeoutMs, sessionTimeoutMs);
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // Advance clock by 1/2 rebalance timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(rebalanceTimeoutMs / 2));

        // Heartbeats to ensure that heartbeating does not interfere with the
        // delayed sync operation.
        joinResponses.forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.NONE));

        // Leader sends a sync group request.
        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId("group-id")
            .withGenerationId(1)
            .withMemberId(joinResponses.get(0).memberId())
            .build());

        // Simulate a successful write to the log.
        syncResult.appendFuture.complete(null);

        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.NONE.code(), syncResult.syncFuture.get().errorCode());
        assertTrue(group.isInState(STABLE));

        // Leader should be able to heartbeat
        context.verifyHeartbeat(group.groupId(), joinResponses.get(0), Errors.NONE);

        // Advance clock by 1/2 rebalance timeout to expire the pending sync. Followers should be removed.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(rebalanceTimeoutMs / 2);
        assertEquals(1, timeouts.size());
        ExpiredTimeout<CoordinatorRecord> timeout = timeouts.get(0);
        assertEquals(classicGroupSyncKey("group-id"), timeout.key());
        assertTrue(timeout.result().records().isEmpty());

        // Leader should be able to heartbeat
        joinResponses.subList(0, 1).forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.REBALANCE_IN_PROGRESS));

        // Heartbeats fail because none of the followers have sent the sync request
        joinResponses.subList(1, 3).forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.UNKNOWN_MEMBER_ID));

        assertTrue(group.isInState(PREPARING_REBALANCE));
    }

    @Test
    public void testRebalanceTimesOutWhenSyncRequestIsNotReceivedFromLeaders() throws Exception {
        // This test case ensure that the pending sync expiration does kick out the leader
        // if it does not send a sync request before the rebalance timeout. The
        // group is in the PreparingRebalance state in this case.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        int rebalanceTimeoutMs = 5000;
        int sessionTimeoutMs = 5000;
        List<JoinGroupResponseData> joinResponses = context.joinWithNMembers("group-id", 3, rebalanceTimeoutMs, sessionTimeoutMs);
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // Advance clock by 1/2 rebalance timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(rebalanceTimeoutMs / 2));

        // Heartbeats to ensure that heartbeating does not interfere with the
        // delayed sync operation.
        joinResponses.forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.NONE));

        // Followers send sync group requests.
        List<CompletableFuture<SyncGroupResponseData>> followerSyncFutures = joinResponses.subList(1, 3).stream()
            .map(response -> {
                GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
                    new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                        .withGroupId("group-id")
                        .withGenerationId(1)
                        .withMemberId(response.memberId())
                        .build()
                );

                assertTrue(syncResult.records.isEmpty());
                assertFalse(syncResult.syncFuture.isDone());
                return syncResult.syncFuture;
            }).toList();

        // Advance clock by 1/2 rebalance timeout to expire the pending sync. Leader should be kicked out.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(rebalanceTimeoutMs / 2);
        assertEquals(1, timeouts.size());
        ExpiredTimeout<CoordinatorRecord> timeout = timeouts.get(0);
        assertEquals(classicGroupSyncKey("group-id"), timeout.key());
        assertTrue(timeout.result().records().isEmpty());

        // Follower sync responses should fail.
        followerSyncFutures.forEach(future -> {
            assertTrue(future.isDone());
            try {
                assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), future.get().errorCode());
            } catch (Exception e) {
                fail("Unexpected exception: " + e.getMessage());
            }
        });

        // Leader heartbeat should fail.
        joinResponses.subList(0, 1).forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.UNKNOWN_MEMBER_ID));

        // Follower heartbeats should succeed.
        joinResponses.subList(1, 3).forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.REBALANCE_IN_PROGRESS));

        assertTrue(group.isInState(PREPARING_REBALANCE));
    }

    @Test
    public void testRebalanceDoesNotTimeOutWhenAllSyncAreReceived() throws Exception {
        // This test case ensure that the pending sync expiration does not kick any
        // members out when they have all sent their sync requests. Group should be in Stable state.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        int rebalanceTimeoutMs = 5000;
        int sessionTimeoutMs = 5000;
        List<JoinGroupResponseData> joinResponses = context.joinWithNMembers("group-id", 3, rebalanceTimeoutMs, sessionTimeoutMs);
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);
        String leaderId = joinResponses.get(0).memberId();

        // Advance clock by 1/2 rebalance timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(rebalanceTimeoutMs / 2));

        // Heartbeats to ensure that heartbeating does not interfere with the
        // delayed sync operation.
        joinResponses.forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.NONE));

        // All members send sync group requests.
        List<CompletableFuture<SyncGroupResponseData>> syncFutures = joinResponses.stream().map(response -> {
            GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
                new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                    .withGroupId("group-id")
                    .withGenerationId(1)
                    .withMemberId(response.memberId())
                    .build()
            );

            if (response.memberId().equals(leaderId)) {
                assertEquals(
                    List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
                    syncResult.records
                );

                // Simulate a successful write to the log.
                syncResult.appendFuture.complete(null);
            } else {
                assertTrue(syncResult.records.isEmpty());
            }
            assertTrue(syncResult.syncFuture.isDone());
            return syncResult.syncFuture;
        }).toList();

        for (CompletableFuture<SyncGroupResponseData> syncFuture : syncFutures) {
            assertEquals(Errors.NONE.code(), syncFuture.get().errorCode());
        }

        // Advance clock by 1/2 rebalance timeout. Pending sync should already have been cancelled.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(rebalanceTimeoutMs / 2));

        // All member heartbeats should succeed.
        joinResponses.forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.NONE));

        // Advance clock a bit more
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(rebalanceTimeoutMs / 2));

        // All member heartbeats should succeed.
        joinResponses.forEach(response -> context.verifyHeartbeat(group.groupId(), response, Errors.NONE));

        assertTrue(group.isInState(STABLE));
    }

    @Test
    public void testHeartbeatDuringRebalanceCausesRebalanceInProgress() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        // First start up a group (with a slightly larger timeout to give us time to heartbeat when the rebalance starts)
        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .withRebalanceTimeoutMs(10000)
            .withSessionTimeoutMs(5000)
            .build();

        JoinGroupResponseData leaderJoinResponse =
            context.joinClassicGroupAsDynamicMemberAndCompleteJoin(joinRequest);

        assertEquals(1, leaderJoinResponse.generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        // Then join with a new consumer to trigger a rebalance
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            joinRequest.setMemberId(UNKNOWN_MEMBER_ID)
        );

        assertTrue(joinResult.records.isEmpty());
        assertFalse(joinResult.joinFuture.isDone());

        // We should be in the middle of a rebalance, so the heartbeat should return rebalance in progress.
        HeartbeatRequestData heartbeatRequest = new HeartbeatRequestData()
            .setGroupId("group-id")
            .setMemberId(leaderJoinResponse.memberId())
            .setGenerationId(leaderJoinResponse.generationId());

        HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(heartbeatRequest).response();
        assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), heartbeatResponse.errorCode());
    }

    @Test
    public void testListGroups() {
        String consumerGroupId = "consumer-group-id";
        String classicGroupId = "classic-group-id";
        String shareGroupId = "share-group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withShareGroupAssignor(assignor)
            .withConsumerGroup(new ConsumerGroupBuilder(consumerGroupId, 10))
            .build();

        // Create one classic group record.
        context.replay(GroupMetadataManagerTestContext.newGroupMetadataRecord(
            classicGroupId,
            new GroupMetadataValue()
                .setMembers(List.of())
                .setGeneration(2)
                .setLeader(null)
                .setProtocolType("classic")
                .setProtocol("range")
                .setCurrentStateTimestamp(context.time.milliseconds())));
        // Create one share group record.
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(shareGroupId, 6, 0));
        context.commit();
        ClassicGroup classicGroup = context.groupMetadataManager.getOrMaybeCreateClassicGroup(classicGroupId, false);
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(consumerGroupId, new ConsumerGroupMember.Builder(memberId1)
            .setSubscribedTopicNames(List.of(fooTopicName))
            .build()));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(consumerGroupId, 11, 0));

        // Test list group response without a group state or group type filter.
        Map<String, ListGroupsResponseData.ListedGroup> actualAllGroupMap =
            context.sendListGroups(List.of(), List.of()).stream()
                .collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));

        Map<String, ListGroupsResponseData.ListedGroup> expectAllGroupMap =
            Stream.of(
                new ListGroupsResponseData.ListedGroup()
                    .setGroupId(classicGroup.groupId())
                    .setProtocolType("classic")
                    .setGroupState(EMPTY.toString())
                    .setGroupType(Group.GroupType.CLASSIC.toString()),
                new ListGroupsResponseData.ListedGroup()
                    .setGroupId(consumerGroupId)
                    .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                    .setGroupState(ConsumerGroup.ConsumerGroupState.EMPTY.toString())
                    .setGroupType(Group.GroupType.CONSUMER.toString()),
                new ListGroupsResponseData.ListedGroup()
                    .setGroupId(shareGroupId)
                    .setProtocolType(ShareGroup.PROTOCOL_TYPE)
                    .setGroupState(ShareGroup.ShareGroupState.EMPTY.toString())
                    .setGroupType(Group.GroupType.SHARE.toString())
            ).collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));

        assertEquals(expectAllGroupMap, actualAllGroupMap);

        // List group with case-insensitive ‘empty’.
        actualAllGroupMap =
            context.sendListGroups(List.of("empty"), List.of())
                .stream().collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));

        assertEquals(expectAllGroupMap, actualAllGroupMap);

        context.commit();

        // Test list group response to check assigning state in the consumer group.
        actualAllGroupMap = context.sendListGroups(List.of("assigning"), List.of()).stream()
            .collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));
        expectAllGroupMap =
            Stream.of(
                new ListGroupsResponseData.ListedGroup()
                    .setGroupId(consumerGroupId)
                    .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                    .setGroupState(ConsumerGroup.ConsumerGroupState.ASSIGNING.toString())
                    .setGroupType(Group.GroupType.CONSUMER.toString())
            ).collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));

        assertEquals(expectAllGroupMap, actualAllGroupMap);

        // Test list group response with group state filter and no group type filter.
        actualAllGroupMap = context.sendListGroups(List.of("Empty"), List.of()).stream()
            .collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));
        expectAllGroupMap = Stream.of(
            new ListGroupsResponseData.ListedGroup()
                .setGroupId(classicGroup.groupId())
                .setProtocolType("classic")
                .setGroupState(EMPTY.toString())
                .setGroupType(Group.GroupType.CLASSIC.toString()),
            new ListGroupsResponseData.ListedGroup()
                .setGroupId(shareGroupId)
                .setProtocolType(ShareGroup.PROTOCOL_TYPE)
                .setGroupState(ShareGroup.ShareGroupState.EMPTY.toString())
                .setGroupType(Group.GroupType.SHARE.toString())
        ).collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));

        assertEquals(expectAllGroupMap, actualAllGroupMap);

        // Test list group response with no group state filter and with group type filter.
        actualAllGroupMap = context.sendListGroups(List.of(), List.of(Group.GroupType.CLASSIC.toString())).stream()
            .collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));
        expectAllGroupMap = Stream.of(
            new ListGroupsResponseData.ListedGroup()
                .setGroupId(classicGroup.groupId())
                .setProtocolType("classic")
                .setGroupState(EMPTY.toString())
                .setGroupType(Group.GroupType.CLASSIC.toString())
        ).collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));

        assertEquals(expectAllGroupMap, actualAllGroupMap);

        // Test list group response with no group state filter and with group type filter in a different case.
        actualAllGroupMap = context.sendListGroups(List.of(), List.of("Consumer")).stream()
            .collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));
        expectAllGroupMap = Stream.of(
            new ListGroupsResponseData.ListedGroup()
                .setGroupId(consumerGroupId)
                .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .setGroupState(ConsumerGroup.ConsumerGroupState.ASSIGNING.toString())
                .setGroupType(Group.GroupType.CONSUMER.toString())
        ).collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));

        assertEquals(expectAllGroupMap, actualAllGroupMap);

        actualAllGroupMap = context.sendListGroups(List.of(), List.of("Share")).stream()
            .collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));
        expectAllGroupMap = Stream.of(
            new ListGroupsResponseData.ListedGroup()
                .setGroupId(shareGroupId)
                .setProtocolType(ShareGroup.PROTOCOL_TYPE)
                .setGroupState(ShareGroup.ShareGroupState.EMPTY.toString())
                .setGroupType(Group.GroupType.SHARE.toString())
        ).collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));

        assertEquals(expectAllGroupMap, actualAllGroupMap);

        actualAllGroupMap = context.sendListGroups(List.of("empty", "Assigning"), List.of()).stream()
            .collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));
        expectAllGroupMap = Stream.of(
            new ListGroupsResponseData.ListedGroup()
                .setGroupId(classicGroup.groupId())
                .setProtocolType(Group.GroupType.CLASSIC.toString())
                .setGroupState(EMPTY.toString())
                .setGroupType(Group.GroupType.CLASSIC.toString()),
            new ListGroupsResponseData.ListedGroup()
                .setGroupId(consumerGroupId)
                .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .setGroupState(ConsumerGroup.ConsumerGroupState.ASSIGNING.toString())
                .setGroupType(Group.GroupType.CONSUMER.toString()),
            new ListGroupsResponseData.ListedGroup()
                .setGroupId(shareGroupId)
                .setProtocolType(ShareGroup.PROTOCOL_TYPE)
                .setGroupState(ShareGroup.ShareGroupState.EMPTY.toString())
                .setGroupType(Group.GroupType.SHARE.toString())
        ).collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));

        assertEquals(expectAllGroupMap, actualAllGroupMap);

        // Test list group response with no group state filter and with invalid group type filter .
        actualAllGroupMap = context.sendListGroups(List.of(), List.of("Invalid")).stream()
            .collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));
        expectAllGroupMap = Map.of();

        assertEquals(expectAllGroupMap, actualAllGroupMap);

        // Test list group response with invalid group state filter and with no group type filter .
        actualAllGroupMap = context.sendListGroups(List.of("Invalid"), List.of()).stream()
            .collect(Collectors.toMap(ListGroupsResponseData.ListedGroup::groupId, Function.identity()));
        expectAllGroupMap = Map.of();

        assertEquals(expectAllGroupMap, actualAllGroupMap);
    }

    @Test
    public void testConsumerGroupDescribeNoErrors() {
        List<String> consumerGroupIds = List.of("group-id-1", "group-id-2");
        int epoch = 10;
        String memberId = "member-id";
        String topicName = "topicName";
        ConsumerGroupMember.Builder memberBuilder = new ConsumerGroupMember.Builder(memberId)
            .setSubscribedTopicNames(List.of(topicName))
            .setServerAssignorName("assignorName");

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withConsumerGroup(new ConsumerGroupBuilder(consumerGroupIds.get(0), epoch))
            .withConsumerGroup(new ConsumerGroupBuilder(consumerGroupIds.get(1), epoch)
                .withMember(memberBuilder.build()))
            .build();

        List<ConsumerGroupDescribeResponseData.DescribedGroup> expected = List.of(
            new ConsumerGroupDescribeResponseData.DescribedGroup()
                .setGroupEpoch(epoch)
                .setGroupId(consumerGroupIds.get(0))
                .setGroupState(ConsumerGroup.ConsumerGroupState.EMPTY.toString())
                .setAssignorName("range")
                .setAssignmentEpoch(1),
            new ConsumerGroupDescribeResponseData.DescribedGroup()
                .setGroupEpoch(epoch)
                .setGroupId(consumerGroupIds.get(1))
                .setMembers(List.of(
                    memberBuilder.build().asConsumerGroupDescribeMember(
                        new Assignment(Map.of()),
                        new MetadataImageBuilder().buildCoordinatorMetadataImage()
                    )
                ))
                .setGroupState(ConsumerGroup.ConsumerGroupState.ASSIGNING.toString())
                .setAssignorName("assignorName")
                .setAssignmentEpoch(1)
        );
        List<ConsumerGroupDescribeResponseData.DescribedGroup> actual = context.sendConsumerGroupDescribe(consumerGroupIds);

        assertEquals(expected, actual);
    }

    @Test
    public void testConsumerGroupDescribeWithErrors() {
        String groupId = "groupId";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .build();

        List<ConsumerGroupDescribeResponseData.DescribedGroup> actual = context.sendConsumerGroupDescribe(List.of(groupId));
        ConsumerGroupDescribeResponseData.DescribedGroup describedGroup = new ConsumerGroupDescribeResponseData.DescribedGroup()
            .setGroupId(groupId)
            .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code())
            .setErrorMessage("Group " + groupId + " not found.");
        List<ConsumerGroupDescribeResponseData.DescribedGroup> expected = List.of(
            describedGroup
        );

        assertEquals(expected, actual);
    }

    @Test
    public void testConsumerGroupDescribeBeforeAndAfterCommittingOffset() {
        String consumerGroupId = "consumerGroupId";
        int epoch = 10;
        String memberId1 = "memberId1";
        String memberId2 = "memberId2";
        String topicName = "topicName";
        Uuid topicId = Uuid.randomUuid();
        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(topicId, topicName, 3)
            .buildCoordinatorMetadataImage();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        ConsumerGroupMember.Builder memberBuilder1 = new ConsumerGroupMember.Builder(memberId1)
            .setSubscribedTopicNames(List.of(topicName));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(consumerGroupId, memberBuilder1.build()));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(consumerGroupId, epoch + 1, 0));

        Map<Uuid, Set<Integer>> assignmentMap = Map.of(topicId, Set.of());

        ConsumerGroupMember.Builder memberBuilder2 = new ConsumerGroupMember.Builder(memberId2);
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(consumerGroupId, memberBuilder2.build()));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(consumerGroupId, memberId2, assignmentMap));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(consumerGroupId, epoch + 1, 12345L));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(consumerGroupId, memberBuilder2.build()));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(consumerGroupId, epoch + 2, 0));

        List<ConsumerGroupDescribeResponseData.DescribedGroup> actual = context.groupMetadataManager.consumerGroupDescribe(List.of(consumerGroupId), context.lastCommittedOffset);
        ConsumerGroupDescribeResponseData.DescribedGroup describedGroup = new ConsumerGroupDescribeResponseData.DescribedGroup()
            .setGroupId(consumerGroupId)
            .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code())
            .setErrorMessage("Group " + consumerGroupId + " not found.");
        List<ConsumerGroupDescribeResponseData.DescribedGroup> expected = List.of(
            describedGroup
        );
        assertEquals(expected, actual);

        // Commit the offset and test again
        context.commit();

        actual = context.groupMetadataManager.consumerGroupDescribe(List.of(consumerGroupId), context.lastCommittedOffset);
        describedGroup = new ConsumerGroupDescribeResponseData.DescribedGroup()
            .setGroupId(consumerGroupId)
            .setMembers(List.of(
                memberBuilder1.build().asConsumerGroupDescribeMember(new Assignment(Map.of()), metadataImage),
                memberBuilder2.build().asConsumerGroupDescribeMember(new Assignment(assignmentMap), metadataImage)
            ))
            .setGroupState(ConsumerGroup.ConsumerGroupState.ASSIGNING.toString())
            .setAssignorName("range")
            .setGroupEpoch(epoch + 2)
            .setAssignmentEpoch(epoch + 1);
        expected = List.of(
            describedGroup
        );
        assertEquals(expected, actual);
    }

    @Test
    public void testStreamsGroupDescribeNoErrors() {
        List<String> streamsGroupIds = Arrays.asList("group-id-1", "group-id-2");
        int epoch = 10;
        String memberId = "member-id";
        StreamsGroupMember.Builder memberBuilder = streamsGroupMemberBuilderWithDefaults(memberId)
            .setClientTags(Map.of("clientTag", "clientValue"))
            .setProcessId("processId")
            .setMemberEpoch(epoch)
            .setPreviousMemberEpoch(epoch - 1);
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        StreamsTopology topology = new StreamsTopology(
            0,
            Map.of(subtopology1,
                new StreamsGroupTopologyValue.Subtopology()
                    .setSubtopologyId(subtopology1)
                    .setSourceTopics(List.of(fooTopicName))
            )
        );

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroup(new StreamsGroupBuilder(streamsGroupIds.get(0), epoch)
                .withTopology(topology)
            )
            .withStreamsGroup(new StreamsGroupBuilder(streamsGroupIds.get(1), epoch)
                .withMember(memberBuilder.build())
                .withTopology(topology)
            )
            .build();

        StreamsGroupDescribeResponseData.Topology expectedTopology =
            new StreamsGroupDescribeResponseData.Topology()
                .setEpoch(0)
                .setSubtopologies(List.of(
                    new StreamsGroupDescribeResponseData.Subtopology()
                        .setSubtopologyId(subtopology1)
                        .setSourceTopics(List.of(fooTopicName))
                ));

        List<StreamsGroupDescribeResponseData.DescribedGroup> expected = Arrays.asList(
            new StreamsGroupDescribeResponseData.DescribedGroup()
                .setGroupEpoch(epoch)
                .setGroupId(streamsGroupIds.get(0))
                .setGroupState(StreamsGroupState.EMPTY.toString())
                .setAssignmentEpoch(1)
                .setTopology(expectedTopology),
            new StreamsGroupDescribeResponseData.DescribedGroup()
                .setGroupEpoch(epoch)
                .setGroupId(streamsGroupIds.get(1))
                .setMembers(List.of(
                    memberBuilder.build().asStreamsGroupDescribeMember(
                        TasksTuple.EMPTY
                    )
                ))
                .setTopology(expectedTopology)
                .setGroupState(StreamsGroupState.NOT_READY.toString())
                .setAssignmentEpoch(1)
        );
        List<StreamsGroupDescribeResponseData.DescribedGroup> actual = context.sendStreamsGroupDescribe(streamsGroupIds);

        assertEquals(expected, actual);
    }

    @Test
    public void testStreamsGroupDescribeWithErrors() {
        String groupId = "groupId";
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();

        List<StreamsGroupDescribeResponseData.DescribedGroup> actual = context.sendStreamsGroupDescribe(List.of(groupId));
        StreamsGroupDescribeResponseData.DescribedGroup describedGroup = new StreamsGroupDescribeResponseData.DescribedGroup()
            .setGroupId(groupId)
            .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code())
            .setErrorMessage("Group groupId not found.");
        List<StreamsGroupDescribeResponseData.DescribedGroup> expected = List.of(describedGroup);

        assertEquals(expected, actual);
    }

    @Test
    public void testStreamsGroupDescribeBeforeAndAfterCommittingOffset() {
        String streamsGroupId = "streamsGroupId";
        int epoch = 10;
        String memberId1 = "memberId1";
        String memberId2 = "memberId2";
        String subtopologyId = "subtopology1";
        String fooTopicName = "foo";
        StreamsGroupTopologyValue topology = new StreamsGroupTopologyValue()
            .setEpoch(0)
            .setSubtopologies(
                List.of(
                    new StreamsGroupTopologyValue.Subtopology()
                        .setSubtopologyId(subtopologyId)
                        .setSourceTopics(List.of(fooTopicName))
                )
            );

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();

        StreamsGroupMember.Builder memberBuilder1 = streamsGroupMemberBuilderWithDefaults(memberId1);
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(streamsGroupId, memberBuilder1.build()));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(streamsGroupId, memberBuilder1.build()));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(streamsGroupId, epoch + 1, 0, -1, Map.of()));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord(streamsGroupId, topology));

        TasksTuple assignment = new TasksTuple(
            Map.of(subtopologyId, Set.of(0, 1)),
            Map.of(subtopologyId, Set.of(0, 1)),
            Map.of(subtopologyId, Set.of(0, 1))
        );

        StreamsGroupMember.Builder memberBuilder2 = streamsGroupMemberBuilderWithDefaults(memberId2);
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(streamsGroupId, memberBuilder2.build()));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(streamsGroupId, memberId2, assignment));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(streamsGroupId, epoch + 1, 12345L));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(streamsGroupId, memberBuilder2.build()));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(streamsGroupId, epoch + 2, 0, 0, Map.of()));

        List<StreamsGroupDescribeResponseData.DescribedGroup> actual = context.groupMetadataManager.streamsGroupDescribe(List.of(streamsGroupId), context.lastCommittedOffset);
        StreamsGroupDescribeResponseData.DescribedGroup describedGroup = new StreamsGroupDescribeResponseData.DescribedGroup()
            .setGroupId(streamsGroupId)
            .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code())
            .setErrorMessage("Group streamsGroupId not found.");
        assertEquals(1, actual.size());
        assertEquals(describedGroup, actual.get(0));

        // Commit the offset and test again
        context.commit();

        actual = context.groupMetadataManager.streamsGroupDescribe(List.of(streamsGroupId), context.lastCommittedOffset);
        describedGroup = new StreamsGroupDescribeResponseData.DescribedGroup()
            .setGroupId(streamsGroupId)
            .setMembers(Arrays.asList(
                memberBuilder1.build().asStreamsGroupDescribeMember(TasksTuple.EMPTY),
                memberBuilder2.build().asStreamsGroupDescribeMember(assignment)
            ))
            .setTopology(
                new StreamsGroupDescribeResponseData.Topology()
                    .setEpoch(0)
                    .setSubtopologies(
                        List.of(
                            new StreamsGroupDescribeResponseData.Subtopology()
                                .setSubtopologyId(subtopologyId)
                                .setSourceTopics(List.of(fooTopicName))
                        )
                    )
            )
            .setGroupState(StreamsGroup.StreamsGroupState.ASSIGNING.toString())
            .setGroupEpoch(epoch + 2)
            .setAssignmentEpoch(epoch + 1);
        assertEquals(1, actual.size());
        assertEquals(describedGroup, actual.get(0));
    }

    @Test
    public void testDescribeGroupStable() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataValue.MemberMetadata memberMetadata = new GroupMetadataValue.MemberMetadata()
            .setMemberId("member-id")
            .setGroupInstanceId("group-instance-id")
            .setClientHost("client-host")
            .setClientId("client-id")
            .setAssignment(new byte[]{0})
            .setSubscription(new byte[]{0, 1, 2});
        GroupMetadataValue groupMetadataValue = new GroupMetadataValue()
            .setMembers(List.of(memberMetadata))
            .setProtocolType("consumer")
            .setProtocol("range")
            .setCurrentStateTimestamp(context.time.milliseconds());

        context.replay(GroupMetadataManagerTestContext.newGroupMetadataRecord(
            "group-id",
            groupMetadataValue
        ));
        context.verifyDescribeGroupsReturnsDeadGroup("group-id");
        context.commit();

        List<DescribeGroupsResponseData.DescribedGroup> expectedDescribedGroups = List.of(
            new DescribeGroupsResponseData.DescribedGroup()
                .setGroupId("group-id")
                .setGroupState(STABLE.toString())
                .setProtocolType(groupMetadataValue.protocolType())
                .setProtocolData(groupMetadataValue.protocol())
                .setMembers(List.of(
                    new DescribeGroupsResponseData.DescribedGroupMember()
                        .setMemberId(memberMetadata.memberId())
                        .setGroupInstanceId(memberMetadata.groupInstanceId())
                        .setClientId(memberMetadata.clientId())
                        .setClientHost(memberMetadata.clientHost())
                        .setMemberMetadata(memberMetadata.subscription())
                        .setMemberAssignment(memberMetadata.assignment())
                ))
        );

        List<DescribeGroupsResponseData.DescribedGroup> describedGroups =
            context.describeGroups(List.of("group-id"));

        assertEquals(expectedDescribedGroups, describedGroups);
    }

    @Test
    public void testDescribeGroupRebalancing() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupMetadataValue.MemberMetadata memberMetadata = new GroupMetadataValue.MemberMetadata()
            .setMemberId("member-id")
            .setGroupInstanceId("group-instance-id")
            .setClientHost("client-host")
            .setClientId("client-id")
            .setAssignment(new byte[]{0})
            .setSubscription(new byte[]{0, 1, 2});
        GroupMetadataValue groupMetadataValue = new GroupMetadataValue()
            .setMembers(List.of(memberMetadata))
            .setProtocolType("consumer")
            .setProtocol("range")
            .setCurrentStateTimestamp(context.time.milliseconds());

        context.replay(GroupMetadataManagerTestContext.newGroupMetadataRecord(
            "group-id",
            groupMetadataValue
        ));
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);
        context.groupMetadataManager.prepareRebalance(group, "trigger rebalance");

        context.verifyDescribeGroupsReturnsDeadGroup("group-id");
        context.commit();

        List<DescribeGroupsResponseData.DescribedGroup> expectedDescribedGroups = List.of(
            new DescribeGroupsResponseData.DescribedGroup()
                .setGroupId("group-id")
                .setGroupState(PREPARING_REBALANCE.toString())
                .setProtocolType(groupMetadataValue.protocolType())
                .setProtocolData("")
                .setMembers(List.of(
                    new DescribeGroupsResponseData.DescribedGroupMember()
                        .setMemberId(memberMetadata.memberId())
                        .setGroupInstanceId(memberMetadata.groupInstanceId())
                        .setClientId(memberMetadata.clientId())
                        .setClientHost(memberMetadata.clientHost())
                        .setMemberAssignment(memberMetadata.assignment())
                ))
        );

        List<DescribeGroupsResponseData.DescribedGroup> describedGroups =
            context.describeGroups(List.of("group-id"));

        assertEquals(expectedDescribedGroups, describedGroups);
    }

    @Test
    public void testDescribeGroupsGroupIdNotFoundException() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.verifyDescribeGroupsReturnsDeadGroup("group-id");
    }

    @Test
    public void testDescribeGroupsBeforeV6GroupIdNotFoundException() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.verifyDescribeGroupsBeforeV6ReturnsDeadGroup("group-id");
    }

    @Test
    public void testGroupStuckInRebalanceTimeoutDueToNonjoinedStaticMember() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        int longSessionTimeoutMs = 10000;
        int rebalanceTimeoutMs = 5000;
        GroupMetadataManagerTestContext.RebalanceResult rebalanceResult = context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id",
            rebalanceTimeoutMs,
            longSessionTimeoutMs
        );
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false);

        // New member joins
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withProtocolSuperset()
                .withSessionTimeoutMs(longSessionTimeoutMs)
                .build()
        );

        // The new dynamic member has been elected as leader
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(rebalanceTimeoutMs));
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(joinResult.joinFuture.get().leader(), joinResult.joinFuture.get().memberId());
        assertEquals(3, joinResult.joinFuture.get().members().size());
        assertEquals(2, joinResult.joinFuture.get().generationId());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        assertEquals(
            Set.of(rebalanceResult.leaderId, rebalanceResult.followerId, joinResult.joinFuture.get().memberId()),
            group.allMemberIds()
        );
        assertEquals(
            Set.of(rebalanceResult.leaderId, rebalanceResult.followerId),
            group.allStaticMemberIds()
        );
        assertEquals(
            Set.of(joinResult.joinFuture.get().memberId()),
            group.allDynamicMemberIds()
        );

        // Send a special leave group request from static follower, moving group towards PreparingRebalance
        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    new MemberIdentity()
                        .setMemberId(rebalanceResult.followerId)
                        .setGroupInstanceId("follower-instance-id")
                ))
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setMemberId(rebalanceResult.followerId)
                    .setGroupInstanceId("follower-instance-id")));

        assertEquals(expectedResponse, leaveResult.response());
        assertTrue(group.isInState(PREPARING_REBALANCE));

        context.sleep(rebalanceTimeoutMs);
        // Only static leader is maintained, and group is stuck at PreparingRebalance stage
        assertTrue(group.allDynamicMemberIds().isEmpty());
        assertEquals(Set.of(rebalanceResult.leaderId), group.allMemberIds());
        assertTrue(group.allDynamicMemberIds().isEmpty());
        assertEquals(2, group.generationId());
        assertTrue(group.isInState(PREPARING_REBALANCE));
    }

    @Test
    public void testPendingMembersLeaveGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");
        JoinGroupResponseData pendingJoinResponse = context.setupGroupWithPendingMember(group).pendingMemberResponse;

        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    new MemberIdentity()
                        .setMemberId(pendingJoinResponse.memberId())
                ))
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId(pendingJoinResponse.memberId())));

        assertEquals(expectedResponse, leaveResult.response());
        assertTrue(leaveResult.records().isEmpty());

        assertTrue(group.isInState(COMPLETING_REBALANCE));
        assertEquals(2, group.allMembers().size());
        assertEquals(2, group.allDynamicMemberIds().size());
        assertEquals(0, group.numPendingJoinMembers());
    }

    @Test
    public void testLeaveGroupInvalidGroup() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("invalid-group-id")
        ));
    }

    @Test
    public void testLeaveGroupUnknownGroup() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("unknown-group-id")
                .setMembers(List.of(
                    new MemberIdentity()
                        .setMemberId("member-id")
                ))
        ));
    }

    @Test
    public void testLeaveGroupUnknownMemberIdExistingGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        context.joinClassicGroupAsDynamicMemberAndCompleteJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withDefaultProtocolTypeAndProtocols()
                .build()
        );

        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    new MemberIdentity()
                        .setMemberId("unknown-member-id")
                ))
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId("unknown-member-id")
                    .setErrorCode(Errors.UNKNOWN_MEMBER_ID.code())));

        assertEquals(expectedResponse, leaveResult.response());
        assertTrue(leaveResult.records().isEmpty());
    }

    @Test
    public void testLeaveDeadGroup() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");
        group.transitionTo(DEAD);

        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    new MemberIdentity()
                        .setMemberId("member-id")
                ))
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setErrorCode(Errors.COORDINATOR_NOT_AVAILABLE.code());

        assertEquals(expectedResponse, leaveResult.response());
        assertTrue(leaveResult.records().isEmpty());
    }

    @Test
    public void testValidLeaveGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        JoinGroupResponseData joinResponse = context.joinClassicGroupAsDynamicMemberAndCompleteJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withDefaultProtocolTypeAndProtocols()
                .build()
        );

        // Dynamic member leaves. The group becomes empty.
        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    new MemberIdentity()
                        .setMemberId(joinResponse.memberId())
                ))
        );
        assertEquals(
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, group.groupAssignment())),
            leaveResult.records()
        );
        // Simulate a successful write to the log.
        leaveResult.appendFuture().complete(null);

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId(joinResponse.memberId())));

        assertEquals(expectedResponse, leaveResult.response());
        assertTrue(group.isInState(EMPTY));
        assertEquals(2, group.generationId());
    }

    @Test
    public void testLeaveGroupWithFencedInstanceId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        context.joinClassicGroupAndCompleteJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withGroupInstanceId("group-instance-id")
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withDefaultProtocolTypeAndProtocols()
                .build(),
            true,
            true
        );

        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    new MemberIdentity()
                        .setGroupInstanceId("group-instance-id")
                        .setMemberId("other-member-id") // invalid member id
                ))
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId("group-instance-id")
                    .setMemberId("other-member-id")
                    .setErrorCode(Errors.FENCED_INSTANCE_ID.code())));

        assertEquals(expectedResponse, leaveResult.response());
        assertTrue(leaveResult.records().isEmpty());
    }

    @Test
    public void testLeaveGroupStaticMemberWithUnknownMemberId() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        context.joinClassicGroupAndCompleteJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withGroupInstanceId("group-instance-id")
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withDefaultProtocolTypeAndProtocols()
                .build(),
            true,
            true
        );

        // Having unknown member id will not affect the request processing due to valid group instance id.
        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    new MemberIdentity()
                        .setGroupInstanceId("group-instance-id")
                        .setMemberId(UNKNOWN_MEMBER_ID)
                ))
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId("group-instance-id")));

        assertEquals(expectedResponse, leaveResult.response());
    }

    @Test
    public void testStaticMembersValidBatchLeaveGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );

        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(
                    List.of(
                        new MemberIdentity()
                            .setGroupInstanceId("leader-instance-id"),
                        new MemberIdentity()
                            .setGroupInstanceId("follower-instance-id")
                    )
                )
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId("leader-instance-id"),
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId("follower-instance-id")));

        assertEquals(expectedResponse, leaveResult.response());
    }

    @Test
    public void testStaticMembersLeaveUnknownGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("invalid-group-id") // Invalid group id
                .setMembers(
                    List.of(
                        new MemberIdentity()
                            .setGroupInstanceId("leader-instance-id"),
                        new MemberIdentity()
                            .setGroupInstanceId("follower-instance-id")
                    )
                )
        ));
    }

    @Test
    public void testStaticMembersFencedInstanceBatchLeaveGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );

        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(
                    List.of(
                        new MemberIdentity()
                            .setGroupInstanceId("leader-instance-id"),
                        new MemberIdentity()
                            .setGroupInstanceId("follower-instance-id")
                            .setMemberId("invalid-member-id")
                    )
                )
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId("leader-instance-id"),
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId("follower-instance-id")
                    .setMemberId("invalid-member-id")
                    .setErrorCode(Errors.FENCED_INSTANCE_ID.code())));

        assertEquals(expectedResponse, leaveResult.response());
    }

    @Test
    public void testStaticMembersUnknownInstanceBatchLeaveGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.staticMembersJoinAndRebalance(
            "group-id",
            "leader-instance-id",
            "follower-instance-id"
        );

        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(
                    List.of(
                        new MemberIdentity()
                            .setGroupInstanceId("unknown-instance-id"), // Unknown instance id
                        new MemberIdentity()
                            .setGroupInstanceId("follower-instance-id")
                    )
                )
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId("unknown-instance-id")
                    .setErrorCode(Errors.UNKNOWN_MEMBER_ID.code()),
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId("follower-instance-id")));

        assertEquals(expectedResponse, leaveResult.response());
        assertTrue(leaveResult.records().isEmpty());
    }

    @Test
    public void testPendingMemberBatchLeaveGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");
        JoinGroupResponseData pendingJoinResponse = context.setupGroupWithPendingMember(group).pendingMemberResponse;

        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(
                    List.of(
                        new MemberIdentity()
                            .setGroupInstanceId("unknown-instance-id"), // Unknown instance id
                        new MemberIdentity()
                            .setMemberId(pendingJoinResponse.memberId())
                    )
                )
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId("unknown-instance-id")
                    .setErrorCode(Errors.UNKNOWN_MEMBER_ID.code()),
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId(pendingJoinResponse.memberId())));

        assertEquals(expectedResponse, leaveResult.response());
    }

    @Test
    public void testJoinedMemberPendingMemberBatchLeaveGroup() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");
        GroupMetadataManagerTestContext.PendingMemberGroupResult pendingMemberGroupResult = context.setupGroupWithPendingMember(group);

        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(
                    List.of(
                        new MemberIdentity()
                            .setMemberId(pendingMemberGroupResult.leaderId),
                        new MemberIdentity()
                            .setMemberId(pendingMemberGroupResult.followerId),
                        new MemberIdentity()
                            .setMemberId(pendingMemberGroupResult.pendingMemberResponse.memberId())
                    )
                )
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId(pendingMemberGroupResult.leaderId),
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId(pendingMemberGroupResult.followerId),
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId(pendingMemberGroupResult.pendingMemberResponse.memberId())));

        assertEquals(expectedResponse, leaveResult.response());
    }

    @Test
    public void testJoinedMemberPendingMemberBatchLeaveGroupWithUnknownMember() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");
        GroupMetadataManagerTestContext.PendingMemberGroupResult pendingMemberGroupResult = context.setupGroupWithPendingMember(group);

        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(
                    List.of(
                        new MemberIdentity()
                            .setMemberId(pendingMemberGroupResult.leaderId),
                        new MemberIdentity()
                            .setMemberId(pendingMemberGroupResult.followerId),
                        new MemberIdentity()
                            .setMemberId(pendingMemberGroupResult.pendingMemberResponse.memberId()),
                        new MemberIdentity()
                            .setMemberId("unknown-member-id")
                    )
                )
        );

        LeaveGroupResponseData expectedResponse = new LeaveGroupResponseData()
            .setMembers(List.of(
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId(pendingMemberGroupResult.leaderId),
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId(pendingMemberGroupResult.followerId),
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId(pendingMemberGroupResult.pendingMemberResponse.memberId()),
                new LeaveGroupResponseData.MemberResponse()
                    .setGroupInstanceId(null)
                    .setMemberId("unknown-member-id")
                    .setErrorCode(Errors.UNKNOWN_MEMBER_ID.code())));

        assertEquals(expectedResponse, leaveResult.response());
    }

    @Test
    public void testClassicGroupDelete() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.createClassicGroup("group-id");

        List<CoordinatorRecord> expectedRecords = List.of(GroupCoordinatorRecordHelpers.newGroupMetadataTombstoneRecord("group-id"));
        List<CoordinatorRecord> records = new ArrayList<>();
        context.groupMetadataManager.createGroupTombstoneRecordsAndCancelTimers("group-id", records);
        assertEquals(expectedRecords, records);
    }

    @Test
    public void testClassicGroupMaybeDelete() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        List<CoordinatorRecord> expectedRecords = List.of(GroupCoordinatorRecordHelpers.newGroupMetadataTombstoneRecord("group-id"));
        List<CoordinatorRecord> records = new ArrayList<>();
        context.groupMetadataManager.maybeDeleteGroup("group-id", records);
        assertEquals(expectedRecords, records);

        records = new ArrayList<>();
        group.transitionTo(PREPARING_REBALANCE);
        context.groupMetadataManager.maybeDeleteGroup("group-id", records);
        assertEquals(List.of(), records);

        records = new ArrayList<>();
        context.groupMetadataManager.maybeDeleteGroup("invalid-group-id", records);
        assertEquals(List.of(), records);
    }

    @Test
    public void testConsumerGroupDelete() {
        String groupId = "group-id";
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataTombstoneRecord(groupId),
            GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord(groupId),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochTombstoneRecord(groupId)
        );
        List<CoordinatorRecord> records = new ArrayList<>();
        context.groupMetadataManager.createGroupTombstoneRecordsAndCancelTimers("group-id", records);
        assertEquals(expectedRecords, records);
    }

    @Test
    public void testStreamsGroupDeleteCancelsInitialRebalanceTimer() {
        String groupId = "streams-group-id";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 2)
                .buildCoordinatorMetadataImage())
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 1000)
            .build();

        assignor.prepareGroupAssignment(
            Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE, TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1))));

        context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        String timerKey = GroupMetadataManager.streamsInitialRebalanceKey(groupId);
        assertTrue(context.timer.isScheduled(timerKey), "Timer should be scheduled after first member joins");

        List<CoordinatorRecord> records = new ArrayList<>();
        context.groupMetadataManager.createGroupTombstoneRecordsAndCancelTimers(groupId, records);

        assertFalse(context.timer.isScheduled(timerKey), "Timer should be cancelled after group deletion");

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord(groupId, memberId),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataTombstoneRecord(groupId),
            StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord(groupId, memberId),
            StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord(groupId),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecordTombstone(groupId)
        );
        assertEquals(expectedRecords, records);
    }

    @Test
    public void testConsumerGroupMaybeDelete() {
        String groupId = "group-id";
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataTombstoneRecord(groupId),
            GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord(groupId),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochTombstoneRecord(groupId)
        );
        List<CoordinatorRecord> records = new ArrayList<>();
        context.groupMetadataManager.maybeDeleteGroup(groupId, records);
        assertEquals(expectedRecords, records);

        records = new ArrayList<>();
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, new ConsumerGroupMember.Builder("member")
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(10)
            .build()));
        context.groupMetadataManager.maybeDeleteGroup(groupId, records);
        assertEquals(List.of(), records);
    }

    @Test
    public void testClassicGroupCompletedRebalanceSensor() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        context.joinClassicGroupAsDynamicMemberAndCompleteRebalance("group-id");
        verify(context.metrics).record(CLASSIC_GROUP_COMPLETED_REBALANCES_SENSOR_NAME);
    }

    @Test
    public void testConsumerGroupRebalanceSensor() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addTopic(barTopicId, barTopicName, 3)
                .addRacks()
                .buildCoordinatorMetadataImage())
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            )))
        ));

        context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setServerAssignor("range")
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        verify(context.metrics).record(CONSUMER_GROUP_REBALANCES_SENSOR_NAME);
    }

    @Test
    public void testConsumerGroupHeartbeatWithNonEmptyClassicGroup() {
        String classicGroupId = "classic-group-id";
        String memberId = Uuid.randomUuid().toString();
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .build();
        ClassicGroup classicGroup = new ClassicGroup(
            new LogContext(),
            classicGroupId,
            EMPTY,
            context.time
        );
        context.replay(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(classicGroup, classicGroup.groupAssignment()));

        context.groupMetadataManager.getOrMaybeCreateClassicGroup(classicGroupId, false).transitionTo(PREPARING_REBALANCE);
        assertThrows(GroupIdNotFoundException.class, () ->
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(classicGroupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setServerAssignor(NoOpPartitionAssignor.NAME)
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setTopicPartitions(List.of())));
    }

    @Test
    public void testConsumerGroupHeartbeatWithEmptyClassicGroup() {
        String classicGroupId = "classic-group-id";
        String memberId = Uuid.randomUuid().toString();
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .build();
        ClassicGroup classicGroup = new ClassicGroup(
            new LogContext(),
            classicGroupId,
            EMPTY,
            context.time
        );
        context.replay(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(classicGroup, classicGroup.groupAssignment()));

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(classicGroupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setServerAssignor(NoOpPartitionAssignor.NAME)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setAssignedPartitions(Map.of())
            .build();

        assertEquals(Errors.NONE.code(), result.response().errorCode());
        assertEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newGroupMetadataTombstoneRecord(classicGroupId),
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(classicGroupId, expectedMember),
                GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(classicGroupId, 2, 0),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(classicGroupId, memberId, Map.of()),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(classicGroupId, 2, context.time.milliseconds()),
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(classicGroupId, expectedMember)
            ),
            result.records()
        );
        assertEquals(
            Group.GroupType.CONSUMER,
            context.groupMetadataManager.consumerGroup(classicGroupId).type()
        );
    }

    @Test
    public void testClassicGroupJoinWithEmptyConsumerGroup() throws Exception {
        String consumerGroupId = "consumer-group-id";
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(consumerGroupId, 10))
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(consumerGroupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request, true);

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataTombstoneRecord(consumerGroupId),
            GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord(consumerGroupId),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochTombstoneRecord(consumerGroupId)
        );

        assertEquals(Errors.MEMBER_ID_REQUIRED.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(expectedRecords, joinResult.records.subList(0, expectedRecords.size()));
        assertEquals(
            Group.GroupType.CLASSIC,
            context.groupMetadataManager.getOrMaybeCreateClassicGroup(consumerGroupId, false).type()
        );
    }

    @Test
    public void testConsumerGroupHeartbeatWithStableClassicGroup() {
        String groupId = "group-id";
        String memberId1 = "member-id-1";
        String memberId2 = "member-id-2";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(barTopicId, 0)
            ))
        )));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 1)
            .addTopic(barTopicId, barTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.UPGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols = new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols.add(new JoinGroupRequestData.JoinGroupRequestProtocol()
            .setName("range")
            .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                List.of(fooTopicName, barTopicName),
                null,
                List.of(
                    new TopicPartition(fooTopicName, 0),
                    new TopicPartition(barTopicName, 0)
                )
            ))))
        );

        Map<String, byte[]> assignments = Map.of(
            memberId1,
            Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
                new TopicPartition(fooTopicName, 0),
                new TopicPartition(barTopicName, 0)
            ))))
        );

        // Create a stable classic group with member 1.
        ClassicGroup group = context.createClassicGroup(groupId);
        group.setProtocolName(Optional.of("range"));
        group.add(
            new ClassicGroupMember(
                memberId1,
                Optional.empty(),
                "client-id",
                "client-host",
                10000,
                5000,
                "consumer",
                protocols,
                assignments.get(memberId1)
            )
        );

        group.transitionTo(PREPARING_REBALANCE);
        group.transitionTo(COMPLETING_REBALANCE);
        group.transitionTo(STABLE);

        context.replay(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, assignments));
        context.commit();
        group = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);

        // A new member 2 with new protocol joins the classic group, triggering the upgrade.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                .setTopicPartitions(List.of()));

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setClientId("client-id")
            .setClientHost("client-host")
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(10000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(protocols))
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0),
                mkTopicAssignment(barTopicId, 0)), 0))
            .build();

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setMemberEpoch(1)
            .setPreviousMemberEpoch(0)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setServerAssignorName("range")
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(5000)
            .setAssignedPartitions(Map.of())
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            // The existing classic group tombstone.
            GroupCoordinatorRecordHelpers.newGroupMetadataTombstoneRecord(groupId),

            // Create the new consumer group with member 1.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 0, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                barTopicName, computeTopicHash(barTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, toAssignmentWithoutEpochs(expectedMember1.assignedPartitions())),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 0, 0),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1),

            // Member 2 joins the new consumer group.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember2),

            // Newly joining member 2 bumps the group epoch. A new target assignment is computed.
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 1, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                barTopicName, computeTopicHash(barTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, assignor.targetPartitions(memberId2)),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, assignor.targetPartitions(memberId1)),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 1, context.time.milliseconds()),

            // Member 2 has no pending revoking partition. Bump its member epoch and transition to UNRELEASED_PARTITIONS.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
        );

        assertRecordsEquals(expectedRecords, result.records());

        context.assertSessionTimeout(groupId, memberId1, expectedMember1.classicProtocolSessionTimeout().get());
        context.assertSessionTimeout(groupId, memberId2, 45000);

        // Simulate a failed replay. The context is rolled back and the group is converted back to the classic group.
        context.rollback();
        assertEquals(group, context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false));
    }

    @Test
    public void testConsumerGroupHeartbeatWithPreparingRebalanceClassicGroup() throws Exception {
        String groupId = "group-id";
        String memberId1 = "member-id-1";
        String memberId2 = "member-id-2";
        String memberId3 = "member-id-3";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(barTopicId, 0)
            )),
            memberId3, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 1)
            ))
        )));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addTopic(barTopicId, barTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.UPGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols1 = new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols1.add(new JoinGroupRequestData.JoinGroupRequestProtocol()
            .setName("range")
            .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                List.of(fooTopicName, barTopicName),
                null,
                List.of(
                    new TopicPartition(fooTopicName, 0),
                    new TopicPartition(fooTopicName, 1)
                )
            ))))
        );

        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols2 = new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols2.add(new JoinGroupRequestData.JoinGroupRequestProtocol()
            .setName("range")
            .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                List.of(fooTopicName, barTopicName),
                null,
                List.of(new TopicPartition(barTopicName, 0))
            ))))
        );

        Map<String, byte[]> assignments = Map.of(
            memberId1, Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
                new TopicPartition(fooTopicName, 0),
                new TopicPartition(fooTopicName, 1)
            )))),
            memberId2, Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
                new TopicPartition(barTopicName, 0)
            ))))
        );

        // Construct a stable group with two members.
        ClassicGroup group = context.createClassicGroup(groupId);
        group.setProtocolName(Optional.of("range"));
        group.add(
            new ClassicGroupMember(
                memberId1,
                Optional.empty(),
                "client-id",
                "client-host",
                10000,
                5000,
                "consumer",
                protocols1,
                assignments.get(memberId1)
            )
        );
        group.add(
            new ClassicGroupMember(
                memberId2,
                Optional.empty(),
                "client-id",
                "client-host",
                10000,
                5000,
                "consumer",
                protocols2,
                assignments.get(memberId2)
            )
        );

        group.transitionTo(PREPARING_REBALANCE);
        group.transitionTo(COMPLETING_REBALANCE);
        group.transitionTo(STABLE);

        context.replay(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, assignments));
        context.commit();
        group = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);

        // The leader rejoins, triggering a rebalance.
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(memberId1)
                .withProtocols(protocols1)
                .withSessionTimeoutMs(5000)
                .withRebalanceTimeoutMs(10000)
                .build()
        );
        assertTrue(group.isInState(PREPARING_REBALANCE));

        // Another new member 3 joins with new protocol, triggering the upgrade.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> consumerGroupHeartbeatResult = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId3)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                .setTopicPartitions(List.of()));

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setClientId("client-id")
            .setClientHost("client-host")
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(10000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(protocols1))
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1)), 0))
            .build();

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setClientId("client-id")
            .setClientHost("client-host")
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(10000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(protocols2))
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(barTopicId, 0)), 0))
            .build();

        ConsumerGroupMember expectedMember3 = new ConsumerGroupMember.Builder(memberId3)
            .setMemberEpoch(1)
            .setPreviousMemberEpoch(0)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setServerAssignorName("range")
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(5000)
            .setAssignedPartitions(Map.of())
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            // The existing classic group tombstone.
            GroupCoordinatorRecordHelpers.newGroupMetadataTombstoneRecord(groupId),

            // Create the new consumer group with member 1 and member 2.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember2),

            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 0, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                barTopicName, computeTopicHash(barTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, toAssignmentWithoutEpochs(expectedMember1.assignedPartitions())),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, toAssignmentWithoutEpochs(expectedMember2.assignedPartitions())),

            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 0, 0),

            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2),

            // Member 3 joins the new consumer group.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember3),

            // Newly joining member 3 bumps the group epoch. A new target assignment is computed.
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 1, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                barTopicName, computeTopicHash(barTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, assignor.targetPartitions(memberId1)),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId3, assignor.targetPartitions(memberId3)),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 1, context.time.milliseconds()),

            // Member 3 has no pending revoking partition. Bump its member epoch and transition to UNRELEASED_PARTITIONS.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember3)
        );

        assertRecordsEquals(expectedRecords, consumerGroupHeartbeatResult.records());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), joinResult.joinFuture.get().errorCode());

        context.assertSessionTimeout(groupId, memberId1, expectedMember1.classicProtocolSessionTimeout().get());
        context.assertSessionTimeout(groupId, memberId2, expectedMember2.classicProtocolSessionTimeout().get());
        context.assertSessionTimeout(groupId, memberId3, 45000);

        // Simulate a failed replay. The context is rolled back and the group is converted back to the classic group.
        context.rollback();
        assertEquals(group, context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false));
    }

    /**
     * Supplies the {@link Arguments} to {@link #testConsumerGroupHeartbeatWithCustomAssignorClassicGroup(ByteBuffer, boolean)}.
     */
    private static Stream<Arguments> testConsumerGroupHeartbeatWithCustomAssignorClassicGroupSource() {
        return Stream.of(
            Arguments.of(null, true),
            Arguments.of(ByteBuffer.allocate(0), true),
            Arguments.of(ByteBuffer.allocate(1), false)
        );
    }

    @ParameterizedTest
    @MethodSource("testConsumerGroupHeartbeatWithCustomAssignorClassicGroupSource")
    public void testConsumerGroupHeartbeatWithCustomAssignorClassicGroup(ByteBuffer userData, boolean expectUpgrade) {
        String groupId = "group-id";
        String memberId1 = "member-id-1";
        String memberId2 = "member-id-2";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(barTopicId, 0)
            ))
        )));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 1)
            .addTopic(barTopicId, barTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.UPGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols = new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols.add(new JoinGroupRequestData.JoinGroupRequestProtocol()
            .setName("range")
            .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                List.of(fooTopicName, barTopicName),
                null,
                List.of(
                    new TopicPartition(fooTopicName, 0),
                    new TopicPartition(barTopicName, 0)
                )
            ))))
        );

        Map<String, byte[]> assignments = Map.of(
            memberId1,
            Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
                new TopicPartition(fooTopicName, 0),
                new TopicPartition(barTopicName, 0)
            ), userData)))
        );

        // Create a stable classic group with member 1.
        ClassicGroup group = context.createClassicGroup(groupId);
        group.setProtocolName(Optional.of("range"));
        group.add(
            new ClassicGroupMember(
                memberId1,
                Optional.empty(),
                "client-id",
                "client-host",
                10000,
                5000,
                "consumer",
                protocols,
                assignments.get(memberId1)
            )
        );

        group.transitionTo(PREPARING_REBALANCE);
        group.transitionTo(COMPLETING_REBALANCE);
        group.transitionTo(STABLE);

        context.replay(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, assignments));
        context.commit();
        group = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);

        // A new member 2 with new protocol joins the classic group, triggering the upgrade.
        ConsumerGroupHeartbeatRequestData consumerGroupHeartbeatRequestData =
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                .setTopicPartitions(List.of());

        if (expectUpgrade) {
            context.consumerGroupHeartbeat(consumerGroupHeartbeatRequestData);
        } else {
            Exception ex = assertThrows(GroupIdNotFoundException.class, () -> context.consumerGroupHeartbeat(consumerGroupHeartbeatRequestData));
            assertEquals(
                "Cannot upgrade classic group group-id to consumer group because an unsupported custom assignor is in use. " +
                "Please refer to the documentation or switch to a default assignor before re-attempting the upgrade.", ex.getMessage());
        }
    }

    @Test
    public void testConsumerGroupHeartbeatToClassicGroupFromExistingStaticMember() {
        String groupId = "group-id";
        String memberId = "member-id";
        String instanceId = "instance-id";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.UPGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .withMetadataImage(metadataImage)
            .build();

        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols = new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols.add(new JoinGroupRequestData.JoinGroupRequestProtocol()
            .setName(NoOpPartitionAssignor.NAME)
            .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                List.of(fooTopicName),
                null,
                List.of(new TopicPartition(fooTopicName, 0))
            ))))
        );

        Map<String, byte[]> assignments = Map.of(
            memberId,
            Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(
                List.of(new TopicPartition(fooTopicName, 0))
            )))
        );

        // Create a stable classic group with a static member.
        ClassicGroup group = context.createClassicGroup(groupId);
        group.setProtocolName(Optional.of(NoOpPartitionAssignor.NAME));
        group.add(
            new ClassicGroupMember(
                memberId,
                Optional.of(instanceId),
                DEFAULT_CLIENT_ID,
                DEFAULT_CLIENT_ADDRESS.toString(),
                10000,
                5000,
                "consumer",
                protocols,
                assignments.get(memberId)
            )
        );

        group.transitionTo(PREPARING_REBALANCE);
        group.initNextGeneration();
        group.transitionTo(STABLE);

        context.replay(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, assignments));
        context.commit();

        // The static member rejoins with new protocol after a restart, triggering the upgrade.
        String newMemberId = Uuid.randomUuid().toString();
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(newMemberId)
                .setInstanceId(instanceId)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor(NoOpPartitionAssignor.NAME)
                .setSubscribedTopicNames(List.of(fooTopicName))
                .setTopicPartitions(List.of()),
            ApiKeys.CONSUMER_GROUP_HEARTBEAT.latestVersion()
        );

        ConsumerGroupMember expectedClassicMember = new ConsumerGroupMember.Builder(memberId)
            .setInstanceId(instanceId)
            .setMemberEpoch(group.generationId())
            .setPreviousMemberEpoch(group.generationId())
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setRebalanceTimeoutMs(10000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(protocols))
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0)), group.generationId()))
            .build();

        // The memberId is generated by the consumer and should be retained
        // for the entire lifetime of the process until termination.
        assertEquals(
            newMemberId,
            result.response().memberId(),
            "Server should not generate a new memberId since the consumer has already generated its own."
        );

        ConsumerGroupMember expectedReplacingConsumerMember = new ConsumerGroupMember.Builder(newMemberId)
            .setInstanceId(instanceId)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setState(MemberState.STABLE)
            .setClientId(expectedClassicMember.clientId())
            .setClientHost(expectedClassicMember.clientHost())
            .setSubscribedTopicNames(new ArrayList<>(expectedClassicMember.subscribedTopicNames()))
            .setRebalanceTimeoutMs(expectedClassicMember.rebalanceTimeoutMs())
            .setAssignedPartitions(toAssignmentWithEpochs(toAssignmentWithoutEpochs(expectedClassicMember.assignedPartitions()), group.generationId()))
            .setClassicMemberMetadata(expectedClassicMember.classicMemberMetadata().get())
            .build();

        ConsumerGroupMember expectedFinalConsumerMember = new ConsumerGroupMember.Builder(expectedReplacingConsumerMember)
            .setMemberEpoch(group.generationId())
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setRebalanceTimeoutMs(5000)
            .setClassicMemberMetadata(null)
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            // The existing classic group tombstone.
            GroupCoordinatorRecordHelpers.newGroupMetadataTombstoneRecord(groupId),

            // Create the new consumer group with the static member.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedClassicMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, group.generationId(), computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, toAssignmentWithoutEpochs(expectedClassicMember.assignedPartitions())),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, group.generationId(), 0),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedClassicMember),

            // Remove the static member because the rejoining member replaces it.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId),

            // Create the new static member.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedReplacingConsumerMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, newMemberId, mkAssignment(mkTopicAssignment(fooTopicId, 0))),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedReplacingConsumerMember),

            // The static member rejoins the new consumer group with the same instance id and
            // takes the assignment of the previous member. No new target assignment is computed.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedFinalConsumerMember),

            // The newly created static member takes the assignment from the existing member.
            // Bump its member epoch and transition to STABLE.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedFinalConsumerMember)
        );

        assertEquals(expectedRecords.size(), result.records().size());
        assertRecordsEquals(expectedRecords.subList(0, 5), result.records().subList(0, 5));
        assertRecordsEquals(expectedRecords.subList(5, 10), result.records().subList(5, 10));

        assertRecordsEquals(expectedRecords, result.records());
        context.assertSessionTimeout(groupId, newMemberId, 45000);
    }

    @Test
    public void testConsumerGroupHeartbeatToClassicGroupWithEmptyAssignmentMember() throws ExecutionException, InterruptedException {
        String groupId = "group-id";
        String memberId2 = "member-id-2";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 1)
            .addTopic(barTopicId, barTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.UPGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .withMetadataImage(metadataImage)
            .build();

        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols = new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols.add(new JoinGroupRequestData.JoinGroupRequestProtocol()
            .setName("range")
            .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                List.of(fooTopicName, barTopicName)
            ))))
        );

        // Member 1 joins, creating a new classic group.
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withProtocols(protocols)
                .withSessionTimeoutMs(5000)
                .withRebalanceTimeoutMs(10000)
                .build()
        );

        // Triggering completion of the rebalance.
        // Member 1 has never synced so its assignment is empty.
        context.sleep(3000 + 1);
        String memberId1 = joinResult.joinFuture.get().memberId();
        ClassicGroup group = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        // A new member 2 with new protocol joins the classic group, triggering the upgrade.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor(NoOpPartitionAssignor.NAME)
                .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                .setTopicPartitions(List.of()));

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setMemberEpoch(1)
            .setPreviousMemberEpoch(1)
            .setState(MemberState.STABLE)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(10000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(protocols))
            )
            .setAssignedPartitions(Map.of())
            .build();

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setState(MemberState.STABLE)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(5000)
            .setAssignedPartitions(Map.of())
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            // The existing classic group tombstone.
            GroupCoordinatorRecordHelpers.newGroupMetadataTombstoneRecord(groupId),

            // Create the new consumer group with member 1.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 1, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                barTopicName, computeTopicHash(barTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, toAssignmentWithoutEpochs(expectedMember1.assignedPartitions())),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 1, 0),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1),

            // Member 2 joins the new consumer group.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember2),

            // Newly joining member 2 bumps the group epoch. A new target assignment is computed.
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 2, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                barTopicName, computeTopicHash(barTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, Map.of()),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),

            // Member 2 has no pending revoking partition or pending release partition.
            // Bump its member epoch and transition to STABLE.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
        );

        assertRecordsEquals(expectedRecords, result.records());

        context.assertSessionTimeout(groupId, memberId1, expectedMember1.classicProtocolSessionTimeout().get());
        context.assertSessionTimeout(groupId, memberId2, 45000);
    }

    @Test
    public void testConsumerGroupHeartbeatFromExistingClassicStaticMember() {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String instanceId1 = "instance-id-1";
        String instanceId2 = "instance-id-2";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(
                        new TopicPartition(fooTopicName, 0),
                        new TopicPartition(fooTopicName, 1),
                        new TopicPartition(fooTopicName, 2),
                        new TopicPartition(barTopicName, 0),
                        new TopicPartition(barTopicName, 1)
                    )
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setInstanceId(instanceId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocols)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2),
                mkTopicAssignment(barTopicId, 0, 1)), 10))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setInstanceId(instanceId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5),
                mkTopicAssignment(barTopicId, 2)), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with two static members.
        // Member 1 uses the classic protocol and member 2 uses the consumer protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.UPGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        // The member 1 with the classic protocol upgrades, heartbeating with new protocol.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setInstanceId(instanceId1)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor(NoOpPartitionAssignor.NAME)
                .setSubscribedTopicNames(new ArrayList<>(member1.subscribedTopicNames()))
                .setTopicPartitions(List.of()),
            ApiKeys.CONSUMER_GROUP_HEARTBEAT.latestVersion()
        );


        // The memberId is generated by the consumer itself, the consumer should retain this memberId
        // for its entire lifetime until the process terminates.
        assertEquals(
            memberId1,
            result.response().memberId(),
            "Server should not generate a new memberId since the consumer has already generated its own."
        );

        ConsumerGroupMember expectedReplacingConsumerMember = new ConsumerGroupMember.Builder(memberId1)
            .setInstanceId(instanceId1)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setState(MemberState.STABLE)
            .setClientId(member1.clientId())
            .setClientHost(member1.clientHost())
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setSubscribedTopicNames(new ArrayList<>(member1.subscribedTopicNames()))
            .setRebalanceTimeoutMs(member1.rebalanceTimeoutMs())
            .setAssignedPartitions(toAssignmentWithEpochs(toAssignmentWithoutEpochs(member1.assignedPartitions()), 10))
            .setClassicMemberMetadata(member1.classicMemberMetadata().get())
            .build();

        ConsumerGroupMember expectedFinalConsumerMember = new ConsumerGroupMember.Builder(expectedReplacingConsumerMember)
            .setMemberEpoch(10)
            .setRebalanceTimeoutMs(5000)
            .setClassicMemberMetadata(null)
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            // Remove the existing static member 1 because the rejoining member replaces it.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),

            // Create the new static member 1.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedReplacingConsumerMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, toAssignmentWithoutEpochs(member1.assignedPartitions())),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedReplacingConsumerMember),

            // The static member rejoins the new consumer group.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedFinalConsumerMember),

            // The newly created static member 1 takes the assignment from the existing member 1.
            // Bump its member epoch and transition to STABLE.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedFinalConsumerMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
        context.assertSessionTimeout(groupId, memberId1, 45000);
    }

    @Test
    public void testConsumerGroupHeartbeatWithCompletingRebalanceClassicGroup() throws Exception {
        String groupId = "group-id";
        String memberId1 = "member-id-1";
        String memberId2 = "member-id-2";
        String memberId3 = "member-id-3";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(barTopicId, 0)
            )),
            memberId3, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 1)
            ))
        )));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addTopic(barTopicId, barTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.UPGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols1 = new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols1.add(new JoinGroupRequestData.JoinGroupRequestProtocol()
            .setName("range")
            .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                List.of(fooTopicName, barTopicName),
                null,
                List.of(
                    new TopicPartition(fooTopicName, 0),
                    new TopicPartition(fooTopicName, 1)
                )
            ))))
        );

        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols2 = new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols2.add(new JoinGroupRequestData.JoinGroupRequestProtocol()
            .setName("range")
            .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                List.of(fooTopicName, barTopicName),
                null,
                List.of(new TopicPartition(barTopicName, 0))
            ))))
        );

        Map<String, byte[]> assignments = Map.of(
            memberId1, Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
                new TopicPartition(fooTopicName, 0),
                new TopicPartition(fooTopicName, 1)
            )))),
            memberId2, Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
                new TopicPartition(barTopicName, 0)
            ))))
        );

        // Construct a stable group with two members.
        ClassicGroup group = context.createClassicGroup(groupId);
        group.setProtocolName(Optional.of("range"));
        group.add(
            new ClassicGroupMember(
                memberId1,
                Optional.empty(),
                "client-id",
                "client-host",
                10000,
                5000,
                "consumer",
                protocols1,
                assignments.get(memberId1)
            )
        );
        group.add(
            new ClassicGroupMember(
                memberId2,
                Optional.empty(),
                "client-id",
                "client-host",
                10000,
                5000,
                "consumer",
                protocols2,
                assignments.get(memberId2)
            )
        );

        group.transitionTo(PREPARING_REBALANCE);
        group.transitionTo(COMPLETING_REBALANCE);
        group.transitionTo(STABLE);

        context.replay(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(group, assignments));
        context.commit();
        group = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);

        // The leader rejoins, triggering a rebalance.
        context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(memberId1)
                .withProtocols(protocols1)
                .withSessionTimeoutMs(5000)
                .withRebalanceTimeoutMs(10000)
                .build()
        );

        // The follower rejoins. All members have rejoined so the group transitions to COMPLETING_REBALANCE state.
        context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(memberId2)
                .withProtocols(protocols2)
                .withSessionTimeoutMs(5000)
                .withRebalanceTimeoutMs(10000)
                .build()
        );
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(memberId2)
                .withGenerationId(1)
                .build());

        // Another new member 3 joins with new protocol, triggering the upgrade.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> consumerGroupHeartbeatResult = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId3)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                .setTopicPartitions(List.of()));

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setMemberEpoch(1)
            .setPreviousMemberEpoch(1)
            .setClientId("client-id")
            .setClientHost("client-host")
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(10000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(protocols1))
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1)), 1))
            .build();

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setMemberEpoch(1)
            .setPreviousMemberEpoch(1)
            .setClientId("client-id")
            .setClientHost("client-host")
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(10000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(protocols2))
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(barTopicId, 0)), 1))
            .build();

        ConsumerGroupMember expectedMember3 = new ConsumerGroupMember.Builder(memberId3)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setServerAssignorName("range")
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(5000)
            .setAssignedPartitions(Map.of())
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            // The existing classic group tombstone.
            GroupCoordinatorRecordHelpers.newGroupMetadataTombstoneRecord(groupId),

            // Create the new consumer group with member 1 and member 2.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember2),

            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 1, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                barTopicName, computeTopicHash(barTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, toAssignmentWithoutEpochs(expectedMember1.assignedPartitions())),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, toAssignmentWithoutEpochs(expectedMember2.assignedPartitions())),

            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 1, 0),

            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2),

            // Member 3 joins the new consumer group.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember3),

            // Newly joining member 3 bumps the group epoch. A new target assignment is computed.
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 2, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                barTopicName, computeTopicHash(barTopicName, metadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, assignor.targetPartitions(memberId1)),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId3, assignor.targetPartitions(memberId3)),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),

            // Member 3 has no pending revoking partition. Bump its member epoch and transition to UNRELEASED_PARTITIONS.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember3)
        );

        assertRecordsEquals(expectedRecords, consumerGroupHeartbeatResult.records());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), syncResult.syncFuture.get().errorCode());

        context.assertSessionTimeout(groupId, memberId1, expectedMember1.classicProtocolSessionTimeout().get());
        context.assertSessionTimeout(groupId, memberId2, expectedMember2.classicProtocolSessionTimeout().get());
        context.assertSessionTimeout(groupId, memberId3, 45000);

        // Simulate a failed replay. The context is rolled back and the group is converted back to the classic group.
        context.rollback();
        assertEquals(group, context.groupMetadataManager.getOrMaybeCreateClassicGroup("group-id", false));
    }

    @Test
    public void testClassicGroupOnUnloadedEmptyAndPreparingRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        ClassicGroup emptyGroup = context.createClassicGroup("empty-group");
        assertTrue(emptyGroup.isInState(EMPTY));

        ClassicGroup preparingGroup = context.createClassicGroup("preparing-group");
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("preparing-group")
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        // preparing-group should have 2 members.
        GroupMetadataManagerTestContext.JoinResult joinResult1 = context.sendClassicGroupJoin(request);
        GroupMetadataManagerTestContext.JoinResult joinResult2 = context.sendClassicGroupJoin(request);

        assertFalse(joinResult1.joinFuture.isDone());
        assertFalse(joinResult2.joinFuture.isDone());
        assertTrue(preparingGroup.isInState(PREPARING_REBALANCE));
        assertEquals(2, preparingGroup.numMembers());

        context.onUnloaded();

        assertTrue(emptyGroup.isInState(DEAD));
        assertTrue(preparingGroup.isInState(DEAD));
        assertTrue(joinResult1.joinFuture.isDone());
        assertTrue(joinResult2.joinFuture.isDone());
        assertEquals(new JoinGroupResponseData()
            .setMemberId(joinResult1.joinFuture.get().memberId())
            .setMembers(List.of())
            .setErrorCode(NOT_COORDINATOR.code()), joinResult1.joinFuture.get());

        assertEquals(new JoinGroupResponseData()
            .setMemberId(joinResult2.joinFuture.get().memberId())
            .setMembers(List.of())
            .setErrorCode(NOT_COORDINATOR.code()), joinResult2.joinFuture.get());
    }

    @Test
    public void testClassicGroupOnUnloadedCompletingRebalance() throws Exception {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();
        ClassicGroup group = context.createClassicGroup("group-id");

        // Set up a group in with a leader, follower, and a pending member.
        // Have the pending member join the group and both the pending member
        // and the follower sync. We should have 2 members awaiting sync.
        GroupMetadataManagerTestContext.PendingMemberGroupResult pendingGroupResult = context.setupGroupWithPendingMember(group);
        String pendingMemberId = pendingGroupResult.pendingMemberResponse.memberId();

        // Compete join group for the pending member
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId("group-id")
            .withMemberId(pendingMemberId)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        assertTrue(joinResult.records.isEmpty());
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.NONE.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(3, group.allMembers().size());
        assertEquals(0, group.numPendingJoinMembers());

        // Follower and pending send SyncGroup request.
        // Follower and pending member should be awaiting sync while the leader is pending sync.
        GroupMetadataManagerTestContext.SyncResult followerSyncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(pendingGroupResult.followerId)
                .withGenerationId(joinResult.joinFuture.get().generationId())
                .build());

        GroupMetadataManagerTestContext.SyncResult pendingMemberSyncResult = context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId("group-id")
                .withMemberId(pendingMemberId)
                .withGenerationId(joinResult.joinFuture.get().generationId())
                .build());

        assertFalse(followerSyncResult.syncFuture.isDone());
        assertFalse(pendingMemberSyncResult.syncFuture.isDone());
        assertTrue(group.isInState(COMPLETING_REBALANCE));

        context.onUnloaded();

        assertTrue(group.isInState(DEAD));
        assertTrue(followerSyncResult.syncFuture.isDone());
        assertTrue(pendingMemberSyncResult.syncFuture.isDone());
        assertEquals(new SyncGroupResponseData()
            .setAssignment(EMPTY_ASSIGNMENT)
            .setErrorCode(NOT_COORDINATOR.code()), followerSyncResult.syncFuture.get());
        assertEquals(new SyncGroupResponseData()
            .setAssignment(EMPTY_ASSIGNMENT)
            .setErrorCode(NOT_COORDINATOR.code()), pendingMemberSyncResult.syncFuture.get());
    }

    @Test
    public void testLastConsumerProtocolMemberLeavingConsumerGroup() {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(
                        new TopicPartition(fooTopicName, 0),
                        new TopicPartition(fooTopicName, 1),
                        new TopicPartition(fooTopicName, 2),
                        new TopicPartition(barTopicName, 0),
                        new TopicPartition(barTopicName, 1)
                    )
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocols)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2),
                mkTopicAssignment(barTopicId, 0, 1)), 10))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5),
                mkTopicAssignment(barTopicId, 2)), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with two members.
        // Member 1 uses the classic protocol and member 2 uses the consumer protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        ConsumerGroup consumerGroup = context.groupMetadataManager.consumerGroup(groupId);

        // Member 2 leaves the consumer group, triggering the downgrade.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));


        byte[] assignment = Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
            new TopicPartition(fooTopicName, 0),
            new TopicPartition(fooTopicName, 1),
            new TopicPartition(fooTopicName, 2),
            new TopicPartition(barTopicName, 0),
            new TopicPartition(barTopicName, 1)
        ))));
        Map<String, byte[]> assignments = Map.of(memberId1, assignment);

        ClassicGroup expectedClassicGroup = new ClassicGroup(
            new LogContext(),
            groupId,
            STABLE,
            context.time,
            10,
            Optional.of(ConsumerProtocol.PROTOCOL_TYPE),
            Optional.of("range"),
            Optional.of(memberId1),
            Optional.of(context.time.milliseconds())
        );
        expectedClassicGroup.add(
            new ClassicGroupMember(
                memberId1,
                Optional.ofNullable(member1.instanceId()),
                member1.clientId(),
                member1.clientHost(),
                member1.rebalanceTimeoutMs(),
                member1.classicProtocolSessionTimeout().get(),
                ConsumerProtocol.PROTOCOL_TYPE,
                member1.supportedJoinGroupRequestProtocols(),
                assignment
            )
        );

        assertUnorderedRecordsEquals(
            List.of(
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2)
                ),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2)
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataTombstoneRecord(groupId)),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2)
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord(groupId)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochTombstoneRecord(groupId)),
                List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(expectedClassicGroup, assignments))
            ),
            result.records()
        );

        // The new classic member 1 has a heartbeat timeout.
        ScheduledTimeout<CoordinatorRecord> heartbeatTimeout = context.timer.timeout(
            classicGroupHeartbeatKey(groupId, memberId1)
        );
        assertNotNull(heartbeatTimeout);
        // The new rebalance has a groupJoin timeout.
        ScheduledTimeout<CoordinatorRecord> groupJoinTimeout = context.timer.timeout(
            classicGroupJoinKey(groupId)
        );
        assertNotNull(groupJoinTimeout);

        // A new rebalance is triggered.
        ClassicGroup classicGroup = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);
        assertTrue(classicGroup.isInState(PREPARING_REBALANCE));

        // Simulate a failed write to the log.
        context.rollback();

        // The group is reverted back to the consumer group.
        assertEquals(consumerGroup, context.groupMetadataManager.consumerGroup(groupId));
    }

    @Test
    public void testLastConsumerProtocolMemberSessionTimeoutInConsumerGroup() {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(
                        new TopicPartition(fooTopicName, 0),
                        new TopicPartition(fooTopicName, 1),
                        new TopicPartition(fooTopicName, 2),
                        new TopicPartition(barTopicName, 0),
                        new TopicPartition(barTopicName, 1)
                    )
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocols)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2),
                mkTopicAssignment(barTopicId, 0, 1)), 10))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5),
                mkTopicAssignment(barTopicId, 2)), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with two members.
        // Member 1 uses the classic protocol and member 2 uses the consumer protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        // Session timer is scheduled on the heartbeat.
        context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of()));

        // Verify that there is a session timeout.
        context.assertSessionTimeout(groupId, memberId2, 45000);

        // Advance time past the session timeout.
        // Member 2 should be fenced from the group, thus triggering the downgrade.
        ExpiredTimeout<CoordinatorRecord> timeout = context.sleep(45000 + 1).get(0);
        assertEquals(groupSessionTimeoutKey(groupId, memberId2), timeout.key());

        byte[] assignment = Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
            new TopicPartition(fooTopicName, 0),
            new TopicPartition(fooTopicName, 1),
            new TopicPartition(fooTopicName, 2),
            new TopicPartition(barTopicName, 0),
            new TopicPartition(barTopicName, 1)
        ))));
        Map<String, byte[]> assignments = Map.of(memberId1, assignment);

        ClassicGroup expectedClassicGroup = new ClassicGroup(
            new LogContext(),
            groupId,
            STABLE,
            context.time,
            10,
            Optional.of(ConsumerProtocol.PROTOCOL_TYPE),
            Optional.of("range"),
            Optional.of(memberId1),
            Optional.of(context.time.milliseconds())
        );
        expectedClassicGroup.add(
            new ClassicGroupMember(
                memberId1,
                Optional.ofNullable(member1.instanceId()),
                member1.clientId(),
                member1.clientHost(),
                member1.rebalanceTimeoutMs(),
                member1.classicProtocolSessionTimeout().get(),
                ConsumerProtocol.PROTOCOL_TYPE,
                member1.supportedJoinGroupRequestProtocols(),
                assignment
            )
        );

        assertUnorderedRecordsEquals(
            List.of(
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2)
                ),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2)
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataTombstoneRecord(groupId)),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2)
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord(groupId)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochTombstoneRecord(groupId)),
                List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(expectedClassicGroup, assignments))
            ),
            timeout.result().records()
        );

        // The new classic member 1 has a heartbeat timeout.
        ScheduledTimeout<CoordinatorRecord> heartbeatTimeout = context.timer.timeout(
            classicGroupHeartbeatKey(groupId, memberId1)
        );
        assertNotNull(heartbeatTimeout);
        // The new rebalance has a groupJoin timeout.
        ScheduledTimeout<CoordinatorRecord> groupJoinTimeout = context.timer.timeout(
            classicGroupJoinKey(groupId)
        );
        assertNotNull(groupJoinTimeout);

        // A new rebalance is triggered.
        ClassicGroup classicGroup = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);
        assertTrue(classicGroup.isInState(PREPARING_REBALANCE));
    }

    @Test
    public void testLastConsumerProtocolMemberRebalanceTimeoutInConsumerGroup() {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Uuid zarTopicId = Uuid.randomUuid();
        String zarTopicName = "zar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(
                        new TopicPartition(fooTopicName, 0),
                        new TopicPartition(fooTopicName, 1),
                        new TopicPartition(fooTopicName, 2),
                        new TopicPartition(barTopicName, 0),
                        new TopicPartition(barTopicName, 1)
                    )
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(30000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocols)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2),
                mkTopicAssignment(barTopicId, 0, 1)), 10))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar", "zar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(30000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5),
                mkTopicAssignment(barTopicId, 2)), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addTopic(zarTopicId, zarTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with two members.
        // Member 1 uses the classic protocol and member 2 uses the consumer protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage),
                    zarTopicName, computeTopicHash(zarTopicName, metadataImage)
                ))))
            .build();

        // Prepare the new assignment.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2),
                mkTopicAssignment(barTopicId, 0, 1)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5)
            ))
        )));

        // Member 2 heartbeats with a different subscribedTopicNames. The assignor computes a new assignment
        // where member 2 will need to revoke topic partition bar-2 thus transitions to the REVOKING state.
        context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of(
                    new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                        .setTopicId(fooTopicId)
                        .setPartitions(List.of(3, 4, 5)),
                    new ConsumerGroupHeartbeatRequestData.TopicPartitions()
                        .setTopicId(barTopicId)
                        .setPartitions(List.of(2))
                ))
        );

        // Verify that there is a rebalance timeout.
        context.assertRebalanceTimeout(groupId, memberId2, 30000);

        // Advance time past the session timeout.
        // Member 2 should be fenced from the group, thus triggering the downgrade.
        ExpiredTimeout<CoordinatorRecord> timeout = context.sleep(30000 + 1).get(0);
        assertEquals(groupRebalanceTimeoutKey(groupId, memberId2), timeout.key());

        byte[] assignment = Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
            new TopicPartition(fooTopicName, 0),
            new TopicPartition(fooTopicName, 1),
            new TopicPartition(fooTopicName, 2),
            new TopicPartition(barTopicName, 0),
            new TopicPartition(barTopicName, 1)
        ))));
        Map<String, byte[]> assignments = Map.of(memberId1, assignment);

        ClassicGroup expectedClassicGroup = new ClassicGroup(
            new LogContext(),
            groupId,
            STABLE,
            context.time,
            11,
            Optional.of(ConsumerProtocol.PROTOCOL_TYPE),
            Optional.of("range"),
            Optional.of(memberId1),
            Optional.of(context.time.milliseconds())
        );
        expectedClassicGroup.add(
            new ClassicGroupMember(
                memberId1,
                Optional.ofNullable(member1.instanceId()),
                member1.clientId(),
                member1.clientHost(),
                member1.rebalanceTimeoutMs(),
                member1.classicProtocolSessionTimeout().get(),
                ConsumerProtocol.PROTOCOL_TYPE,
                member1.supportedJoinGroupRequestProtocols(),
                assignment
            )
        );

        assertUnorderedRecordsEquals(
            List.of(
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2)
                ),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2)
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataTombstoneRecord(groupId)),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2)
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord(groupId)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochTombstoneRecord(groupId)),
                List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(expectedClassicGroup, assignments))
            ),
            timeout.result().records()
        );

        // The new classic member 1 has a heartbeat timeout.
        ScheduledTimeout<CoordinatorRecord> heartbeatTimeout = context.timer.timeout(
            classicGroupHeartbeatKey(groupId, memberId1)
        );
        assertNotNull(heartbeatTimeout);
        // The new rebalance has a groupJoin timeout.
        ScheduledTimeout<CoordinatorRecord> groupJoinTimeout = context.timer.timeout(
            classicGroupJoinKey(groupId)
        );
        assertNotNull(groupJoinTimeout);

        // A new rebalance is triggered.
        ClassicGroup classicGroup = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);
        assertTrue(classicGroup.isInState(PREPARING_REBALANCE));
    }

    @ParameterizedTest
    @ValueSource(booleans = {true, false})
    public void testLastStaticConsumerProtocolMemberReplacedByClassicProtocolMember(
        boolean isSubscriptionChanged
    ) throws ExecutionException, InterruptedException {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String oldMemberId2 = Uuid.randomUuid().toString();
        String instanceId = "instance-id";

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols1 = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName(NoOpPartitionAssignor.NAME)
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(
                        new TopicPartition(fooTopicName, 0),
                        new TopicPartition(fooTopicName, 1),
                        new TopicPartition(fooTopicName, 2),
                        new TopicPartition(barTopicName, 0),
                        new TopicPartition(barTopicName, 1)
                    )
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocols1)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2),
                mkTopicAssignment(barTopicId, 0, 1)), 10))
            .build();
        ConsumerGroupMember oldMember2 = new ConsumerGroupMember.Builder(oldMemberId2)
            .setInstanceId(instanceId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5)), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 2)
            .addRacks()
            .buildCoordinatorMetadataImage();
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);

        // Consumer group with two members.
        // Member 1 uses the classic protocol and static member 2 uses the consumer protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(oldMember2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(oldMemberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        context.groupMetadataManager.consumerGroup(groupId).setMetadataRefreshDeadline(Long.MAX_VALUE, 10);

        // A new member using classic protocol with the same instance id joins, scheduling the downgrade.
        byte[] protocolsMetadata2 = Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
            isSubscriptionChanged ? List.of(fooTopicName, barTopicName) : List.of(fooTopicName))));
        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols2 =
            new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols2.add(new JoinGroupRequestProtocol()
            .setName(NoOpPartitionAssignor.NAME)
            .setMetadata(protocolsMetadata2));
        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId(instanceId)
            .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
            .withProtocols(protocols2)
            .build();
        GroupMetadataManagerTestContext.JoinResult result = context.sendClassicGroupJoin(joinRequest);
        result.appendFuture.complete(null);
        String newMemberId2 = result.joinFuture.get().memberId();

        ConsumerGroupMember expectedNewConsumerMember2 = new ConsumerGroupMember.Builder(oldMember2, newMemberId2)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .build();
        ConsumerGroupMember expectedNewClassicMember2 = new ConsumerGroupMember.Builder(oldMember2, newMemberId2)
            .setPreviousMemberEpoch(0)
            .setMemberEpoch(isSubscriptionChanged ? 11 : 10)
            .setSubscribedTopicNames(isSubscriptionChanged ? List.of(fooTopicName, barTopicName) : List.of(fooTopicName))
            .setRebalanceTimeoutMs(joinRequest.rebalanceTimeoutMs())
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(joinRequest.sessionTimeoutMs())
                    .setSupportedProtocols(List.of(new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                        .setName(NoOpPartitionAssignor.NAME)
                        .setMetadata(protocolsMetadata2)))
            ).build();

        byte[] assignment1 = Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
            new TopicPartition(fooTopicName, 0),
            new TopicPartition(fooTopicName, 1),
            new TopicPartition(fooTopicName, 2),
            new TopicPartition(barTopicName, 0),
            new TopicPartition(barTopicName, 1)
        ))));
        byte[] assignment2 = Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
            new TopicPartition(fooTopicName, 3),
            new TopicPartition(fooTopicName, 4),
            new TopicPartition(fooTopicName, 5)
        ))));
        Map<String, byte[]> assignments = Map.of(
            memberId1, assignment1,
            newMemberId2, assignment2
        );

        ClassicGroup expectedClassicGroup = new ClassicGroup(
            new LogContext(),
            groupId,
            STABLE,
            context.time,
            10,
            Optional.of(ConsumerProtocol.PROTOCOL_TYPE),
            Optional.of(NoOpPartitionAssignor.NAME),
            Optional.of(memberId1),
            Optional.of(context.time.milliseconds())
        );
        expectedClassicGroup.add(
            new ClassicGroupMember(
                memberId1,
                Optional.ofNullable(member1.instanceId()),
                member1.clientId(),
                member1.clientHost(),
                member1.rebalanceTimeoutMs(),
                member1.classicProtocolSessionTimeout().get(),
                ConsumerProtocol.PROTOCOL_TYPE,
                member1.supportedJoinGroupRequestProtocols(),
                assignment1
            )
        );
        expectedClassicGroup.add(
            new ClassicGroupMember(
                newMemberId2,
                Optional.ofNullable(oldMember2.instanceId()),
                DEFAULT_CLIENT_ID,
                DEFAULT_CLIENT_ADDRESS.toString(),
                joinRequest.rebalanceTimeoutMs(),
                joinRequest.sessionTimeoutMs(),
                joinRequest.protocolType(),
                joinRequest.protocols(),
                assignment2
            )
        );

        // The leader of the classic group is not deterministic.
        String leader = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false).leaderOrNull();
        assertTrue(Set.of(memberId1, newMemberId2).contains(leader));
        expectedClassicGroup.setLeaderId(Optional.of(leader));

        List<List<CoordinatorRecord>> replacingRecords = List.of(
            // Remove the existing member 2 that uses the consumer protocol.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, oldMemberId2)),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, oldMemberId2)),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, oldMemberId2)),

            // Create the new member 2 that uses the consumer protocol.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedNewConsumerMember2)),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, newMemberId2, toAssignmentWithoutEpochs(expectedNewConsumerMember2.assignedPartitions()))),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedNewConsumerMember2))
        );

        List<List<CoordinatorRecord>> memberUpdateRecords;
        if (isSubscriptionChanged) {
            memberUpdateRecords = List.of(
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedNewClassicMember2)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash
                ))))
            );
        } else {
            memberUpdateRecords = List.of(
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedNewClassicMember2)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedNewClassicMember2))
            );
        }

        List<List<CoordinatorRecord>> downgradeRecords = List.of(
            // Remove member 1, member 2 and the consumer group.
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, newMemberId2)
            ),
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, newMemberId2)
            ),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataTombstoneRecord(groupId)),
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, newMemberId2)
            ),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord(groupId)),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochTombstoneRecord(groupId)),

            // Create the classic group.
            List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(expectedClassicGroup, assignments))
        );

        assertUnorderedRecordsEquals(
            Stream.of(replacingRecords, memberUpdateRecords, downgradeRecords)
                .flatMap(List::stream)
                .collect(Collectors.toList()),
            result.records
        );

        // The new classic member 1 has a heartbeat timeout.
        ScheduledTimeout<CoordinatorRecord> heartbeatTimeout = context.timer.timeout(
            classicGroupHeartbeatKey(groupId, memberId1)
        );
        assertNotNull(heartbeatTimeout);

        // If the subscription is changed, a rebalance is triggered.
        ClassicGroup classicGroup = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);
        if (isSubscriptionChanged) {
            assertTrue(classicGroup.isInState(PREPARING_REBALANCE));
        } else {
            assertTrue(classicGroup.isInState(STABLE));
        }
    }

    @Test
    public void testLastStaticConsumerProtocolMemberReplacedByClassicProtocolMemberWhenTargetAssignmentIsStale() throws ExecutionException, InterruptedException {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String oldMemberId2 = Uuid.randomUuid().toString();
        String instanceId = "instance-id";

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols1 = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName(NoOpPartitionAssignor.NAME)
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(
                        new TopicPartition(fooTopicName, 0),
                        new TopicPartition(fooTopicName, 1),
                        new TopicPartition(fooTopicName, 2),
                        new TopicPartition(barTopicName, 0),
                        new TopicPartition(barTopicName, 1)
                    )
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocols1)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2),
                mkTopicAssignment(barTopicId, 0, 1)), 10))
            .build();
        ConsumerGroupMember oldMember2 = new ConsumerGroupMember.Builder(oldMemberId2)
            .setInstanceId(instanceId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5)), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 2)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with two members.
        // Member 1 uses the classic protocol and static member 2 uses the consumer protocol.
        // Member 2 has just changed subscription from foo to bar and the new assignment has not
        // been computed yet.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 11)
                .withMember(member1)
                .withMember(oldMember2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(oldMemberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        // A new member using classic protocol with the same instance id joins, scheduling the downgrade.
        byte[] protocolsMetadata2 = Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
            List.of(fooTopicName, barTopicName))));
        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols2 =
            new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols2.add(new JoinGroupRequestProtocol()
            .setName(NoOpPartitionAssignor.NAME)
            .setMetadata(protocolsMetadata2));
        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId(instanceId)
            .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
            .withProtocols(protocols2)
            .build();
        GroupMetadataManagerTestContext.JoinResult result = context.sendClassicGroupJoin(joinRequest);
        result.appendFuture.complete(null);
        result.joinFuture.get();

        // A rebalance is triggered.
        ClassicGroup classicGroup = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);
        assertTrue(classicGroup.isInState(PREPARING_REBALANCE));
    }

    @Test
    public void testLastStaticConsumerProtocolMemberReplacedByClassicProtocolMemberWhenTargetAssignmentIsMissing() throws ExecutionException, InterruptedException {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String oldMemberId2 = Uuid.randomUuid().toString();
        String instanceId = "instance-id";

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols1 = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName(NoOpPartitionAssignor.NAME)
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of()
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocols1)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(), 10))
            .build();
        ConsumerGroupMember oldMember2 = new ConsumerGroupMember.Builder(oldMemberId2)
            .setInstanceId(instanceId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setServerAssignorName(NoOpPartitionAssignor.NAME)
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1)), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 2)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with two members.
        // Member 1 uses the classic protocol and static member 2 uses the consumer protocol.
        // Member 1 has just joined and does not have an assignment yet.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 11)
                .withMember(member1)
                .withMember(oldMember2)
                .withAssignment(oldMemberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        // A new member using classic protocol with the same instance id joins, scheduling the downgrade.
        byte[] protocolsMetadata2 = Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
            List.of(fooTopicName, barTopicName))));
        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols2 =
            new JoinGroupRequestData.JoinGroupRequestProtocolCollection(1);
        protocols2.add(new JoinGroupRequestProtocol()
            .setName(NoOpPartitionAssignor.NAME)
            .setMetadata(protocolsMetadata2));
        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId(instanceId)
            .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
            .withProtocols(protocols2)
            .build();
        GroupMetadataManagerTestContext.JoinResult result = context.sendClassicGroupJoin(joinRequest);
        result.appendFuture.complete(null);
        result.joinFuture.get();

        // A rebalance is triggered.
        ClassicGroup classicGroup = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);
        assertTrue(classicGroup.isInState(PREPARING_REBALANCE));
    }

    @Test
    public void testJoiningConsumerGroupThrowsExceptionIfGroupOverMaxSize() {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .build()))
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MAX_SIZE_CONFIG, 1)
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        Exception ex = assertThrows(GroupMaxSizeReachedException.class, () -> context.sendClassicGroupJoin(request));
        assertEquals("The consumer group has reached its maximum capacity of 1 members.", ex.getMessage());
    }

    @Test
    public void testJoiningConsumerGroupThrowsExceptionIfProtocolIsNotSupported() {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(
                                GroupMetadataManagerTestContext.toProtocols("roundrobin")
                            ))
                    )
                    .build()))
            .build();

        JoinGroupRequestData requestWithEmptyProtocols = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
            .withDefaultProtocolTypeAndProtocols()
            .build();
        assertThrows(InconsistentGroupProtocolException.class, () -> context.sendClassicGroupJoin(requestWithEmptyProtocols));

        JoinGroupRequestData requestWithInvalidProtocolType = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("connect")
            .withDefaultProtocolTypeAndProtocols()
            .build();
        assertThrows(InconsistentGroupProtocolException.class, () -> context.sendClassicGroupJoin(requestWithInvalidProtocolType));
    }

    @ParameterizedTest
    @ValueSource(booleans = {true, false})
    public void testClassicMemberJoinToConsumerGroupWithDisabledMigrationPolicy(boolean isStatic) {
        String groupId = "group-id";
        String classicMemberId = Uuid.randomUuid().toString();
        String classicInstanceId = "classic-instance-id";
        String consumerMemberId = Uuid.randomUuid().toString();
        String consumerInstanceId = "consumer-instance-id";
        String errorMessage = String.format(
            "Cannot join the consumer group %s with the classic protocol because the group migration is disabled.", groupId);

        // The group already contains a classic member. For the static case it also contains a
        // consumer-protocol static member that a classic member could try to replace.
        ConsumerGroupBuilder groupBuilder = new ConsumerGroupBuilder(groupId, 10)
            .withMember(new ConsumerGroupMember.Builder(classicMemberId)
                .setState(MemberState.STABLE)
                .setMemberEpoch(10)
                .setPreviousMemberEpoch(10)
                .setInstanceId(isStatic ? classicInstanceId : null)
                .setClassicMemberMetadata(
                    new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                        .setSessionTimeoutMs(5000)
                        .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(
                            GroupMetadataManagerTestContext.toProtocols("range"))))
                .build());
        if (isStatic) {
            groupBuilder.withMember(new ConsumerGroupMember.Builder(consumerMemberId)
                .setState(MemberState.STABLE)
                .setMemberEpoch(10)
                .setPreviousMemberEpoch(10)
                .setInstanceId(consumerInstanceId)
                .build());
        }
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DISABLED.toString())
            .withConsumerGroup(groupBuilder)
            .build();

        // A new classic member cannot join
        JoinGroupRequestData newMemberRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId(isStatic ? "new-instance-id" : null)
            .withProtocols(GroupMetadataManagerTestContext.toProtocols("range"))
            .build();
        assertEquals(errorMessage,
            assertThrows(InconsistentGroupProtocolException.class, () -> context.sendClassicGroupJoin(newMemberRequest, isStatic)).getMessage());

        // The existing classic member cannot rejoin.
        JoinGroupRequestData rejoinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(isStatic ? UNKNOWN_MEMBER_ID : classicMemberId)
            .withGroupInstanceId(isStatic ? classicInstanceId : null)
            .withProtocols(GroupMetadataManagerTestContext.toProtocols("range"))
            .build();
        assertEquals(errorMessage,
            assertThrows(InconsistentGroupProtocolException.class, () -> context.sendClassicGroupJoin(rejoinRequest, isStatic)).getMessage());

        if (isStatic) {
            // A classic member cannot replace an existing consumer-protocol static member.
            JoinGroupRequestData replaceRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withGroupInstanceId(consumerInstanceId)
                .withProtocols(GroupMetadataManagerTestContext.toProtocols("range"))
                .build();
            assertEquals(errorMessage,
                assertThrows(InconsistentGroupProtocolException.class, () -> context.sendClassicGroupJoin(replaceRequest, true)).getMessage());
        }
    }

    @Test
    public void testJoiningConsumerGroupWithNewDynamicMember() throws Exception {
        String groupId = "group-id";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        for (short version = ConsumerProtocolSubscription.LOWEST_SUPPORTED_VERSION; version <= ConsumerProtocolSubscription.HIGHEST_SUPPORTED_VERSION; version++) {
            String memberId = Uuid.randomUuid().toString();
            MockPartitionAssignor assignor = new MockPartitionAssignor("range");

            CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 2)
                .addTopic(barTopicId, barTopicName, 1)
                .addRacks()
                .buildCoordinatorMetadataImage();
            long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
            long barTopicHash = computeTopicHash(barTopicName, metadataImage);

            GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
                .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
                .withMetadataImage(metadataImage)
                .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                    .withMember(new ConsumerGroupMember.Builder(memberId)
                        .setState(MemberState.STABLE)
                        .setMemberEpoch(10)
                        .setPreviousMemberEpoch(10)
                        .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                            mkTopicAssignment(fooTopicId, 0, 1)), 10))
                        .build())
                    .withAssignment(memberId, mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1)))
                    .withAssignmentEpoch(10)
                    .withMetadataHash(computeGroupHash(Map.of(
                        fooTopicName, fooTopicHash
                    ))))
                .build();

            JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(UNKNOWN_MEMBER_ID)
                .withProtocols(GroupMetadataManagerTestContext.toConsumerProtocol(
                    List.of(fooTopicName, barTopicName),
                    List.of(),
                    version))
                .build();

            // The first round of join request gets the new member id.
            GroupMetadataManagerTestContext.JoinResult firstJoinResult = context.sendClassicGroupJoin(
                request,
                true
            );
            assertTrue(firstJoinResult.records.isEmpty());
            // Simulate a successful write to the log.
            firstJoinResult.appendFuture.complete(null);

            assertTrue(firstJoinResult.joinFuture.isDone());
            assertEquals(Errors.MEMBER_ID_REQUIRED.code(), firstJoinResult.joinFuture.get().errorCode());
            String newMemberId = firstJoinResult.joinFuture.get().memberId();
            assertNotEquals("", newMemberId);

            assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
                memberId, new MemberAssignmentImpl(mkAssignment(
                    mkTopicAssignment(fooTopicId, 0)
                )),
                newMemberId, new MemberAssignmentImpl(mkAssignment(
                    mkTopicAssignment(barTopicId, 0)
                ))
            )));

            JoinGroupRequestData secondRequest = new JoinGroupRequestData()
                .setGroupId(request.groupId())
                .setMemberId(newMemberId)
                .setProtocolType(request.protocolType())
                .setProtocols(request.protocols())
                .setSessionTimeoutMs(request.sessionTimeoutMs())
                .setRebalanceTimeoutMs(request.rebalanceTimeoutMs())
                .setReason(request.reason());

            // Send second join group request for a new dynamic member with the new member id.
            GroupMetadataManagerTestContext.JoinResult secondJoinResult = context.sendClassicGroupJoin(
                secondRequest,
                true
            );

            ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(newMemberId)
                .setMemberEpoch(11)
                .setPreviousMemberEpoch(0)
                .setState(MemberState.STABLE)
                .setClientId(DEFAULT_CLIENT_ID)
                .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                .setRebalanceTimeoutMs(500)
                .setAssignedPartitions(toAssignmentWithEpochs(assignor.targetPartitions(newMemberId), 11))
                .setClassicMemberMetadata(
                    new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                        .setSessionTimeoutMs(request.sessionTimeoutMs())
                        .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(request.protocols()))
                )
                .build();

            assertUnorderedRecordsEquals(
                List.of(
                    List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember)),
                    List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                        fooTopicName, fooTopicHash,
                        barTopicName, barTopicHash
                    )))),

                    List.of(
                        GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId, assignor.targetPartitions(memberId)),
                        GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, newMemberId, assignor.targetPartitions(newMemberId))
                    ),
                    List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds())),

                    List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember))
                ),
                secondJoinResult.records
            );

            secondJoinResult.appendFuture.complete(null);
            assertTrue(secondJoinResult.joinFuture.isDone());
            assertEquals(
                new JoinGroupResponseData()
                    .setMemberId(newMemberId)
                    .setGenerationId(11)
                    .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                    .setProtocolName("range"),
                secondJoinResult.joinFuture.get()
            );

            context.assertSessionTimeout(groupId, newMemberId, request.sessionTimeoutMs());
            context.assertSyncTimeout(groupId, newMemberId, request.rebalanceTimeoutMs());
        }
    }

    @Test
    public void testJoiningConsumerGroupFailingToPersistRecords() throws Exception {
        String groupId = "group-id";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        String memberId = Uuid.randomUuid().toString();
        String newMemberId = Uuid.randomUuid().toString();

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0)
            )),
            newMemberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 1)
            ))
        )));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addRacks()
            .buildCoordinatorMetadataImage();
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1)), 10))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage)
                ))))
            .build();
        context.commit();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(newMemberId)
            .withProtocols(GroupMetadataManagerTestContext.toConsumerProtocol(
                List.of(fooTopicName),
                List.of()))
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        // Simulate a failed write to the log.
        joinResult.appendFuture.completeExceptionally(new NotLeaderOrFollowerException());
        context.rollback();

        context.assertNoSessionTimeout(groupId, newMemberId);
        context.assertNoSyncTimeout(groupId, newMemberId);
        assertFalse(context.groupMetadataManager.consumerGroup(groupId).hasMember(newMemberId));
    }

    @Test
    public void testJoiningConsumerGroupWithNewStaticMember() throws Exception {
        String groupId = "group-id";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        String memberId = Uuid.randomUuid().toString();
        String instanceId = "instance-id";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addTopic(barTopicId, barTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1)), 10))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(fooTopicName, fooTopicHash))))
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId(instanceId)
            .withProtocols(GroupMetadataManagerTestContext.toConsumerProtocol(
                List.of(fooTopicName, barTopicName),
                List.of()))
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        // Simulate a successful write to log.
        joinResult.appendFuture.complete(null);
        String newMemberId = joinResult.joinFuture.get().memberId();
        assertNotEquals("", newMemberId);

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(newMemberId)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(0)
            .setInstanceId(instanceId)
            .setState(MemberState.STABLE)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
            .setRebalanceTimeoutMs(500)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(request.sessionTimeoutMs())
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(request.protocols()))
            )
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, fooTopicHash,
                barTopicName, barTopicHash
            ))),

            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, newMemberId, Map.of()),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),

            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember)
        );
        assertRecordsEquals(expectedRecords, joinResult.records);

        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(
            new JoinGroupResponseData()
                .setMemberId(newMemberId)
                .setGenerationId(11)
                .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .setProtocolName("range"),
            joinResult.joinFuture.get()
        );

        context.assertSessionTimeout(groupId, newMemberId, request.sessionTimeoutMs());
        context.assertSyncTimeout(groupId, newMemberId, request.rebalanceTimeoutMs());
    }

    @Test
    public void testJoiningConsumerGroupReplacingExistingStaticMember() throws Exception {
        String groupId = "group-id";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addRacks()
            .buildCoordinatorMetadataImage();

        String memberId = Uuid.randomUuid().toString();
        String instanceId = "instance-id";
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.UPGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(new NoOpPartitionAssignor()))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setInstanceId(instanceId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setSubscribedTopicNames(List.of(fooTopicName))
                    .setRebalanceTimeoutMs(500)
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1)), 10))
                    .build())
                .withAssignment(memberId, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage)
                ))))
            .build();
        context.groupMetadataManager.consumerGroup(groupId).setMetadataRefreshDeadline(Long.MAX_VALUE, 10);

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId(instanceId)
            .withProtocols(GroupMetadataManagerTestContext.toConsumerProtocol(
                List.of(fooTopicName),
                List.of()))
            .build();

        // The static member joins with UNKNOWN_MEMBER_ID.
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(
            request,
            true
        );

        // Simulate a successful write to log.
        joinResult.appendFuture.complete(null);
        String newMemberId = joinResult.joinFuture.get().memberId();
        assertNotEquals("", newMemberId);

        ConsumerGroupMember expectedCopiedMember = new ConsumerGroupMember.Builder(newMemberId)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setInstanceId(instanceId)
            .setState(MemberState.STABLE)
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1)), 10))
            .setRebalanceTimeoutMs(500)
            .build();

        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(newMemberId)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(0)
            .setInstanceId(instanceId)
            .setState(MemberState.STABLE)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1)), 10))
            .setRebalanceTimeoutMs(500)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(request.sessionTimeoutMs())
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(request.protocols())))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            // Remove the old static member.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId),

            // Replace the old static member by the new static member.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedCopiedMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, newMemberId, mkAssignment(mkTopicAssignment(fooTopicId, 0, 1))),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedCopiedMember),

            // Updated the new static member.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember)
        );
        assertRecordsEquals(expectedRecords, joinResult.records);
        assertEquals(
            new JoinGroupResponseData()
                .setMemberId(newMemberId)
                .setGenerationId(10)
                .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .setProtocolName("range"),
            joinResult.joinFuture.get()
        );

        context.assertSessionTimeout(groupId, newMemberId, request.sessionTimeoutMs());
        context.assertSyncTimeout(groupId, newMemberId, request.rebalanceTimeoutMs());
    }

    @Test
    public void testJoiningConsumerGroupWithExistingStaticMemberAndNewSubscription() throws Exception {
        String groupId = "group-id";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Uuid zarTopicId = Uuid.randomUuid();
        String zarTopicName = "zar";

        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String instanceId = "instance-id";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addTopic(barTopicId, barTopicName, 1)
            .addTopic(zarTopicId, zarTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();
        long groupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, metadataImage),
            barTopicName, computeTopicHash(barTopicName, metadataImage),
            zarTopicName, computeTopicHash(zarTopicName, metadataImage)
        ));

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setInstanceId(instanceId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setRebalanceTimeoutMs(500)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0),
                        mkTopicAssignment(barTopicId, 0)), 10))
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(
                                GroupMetadataManagerTestContext.toConsumerProtocol(
                                    List.of(fooTopicName, barTopicName),
                                    List.of(new TopicPartition(fooTopicName, 0), new TopicPartition(fooTopicName, 1))
                                )
                            ))
                    )
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setRebalanceTimeoutMs(500)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 1)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0),
                    mkTopicAssignment(barTopicId, 0)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 1)))
                .withAssignmentEpoch(10)
                .withMetadataHash(groupMetadataHash))
            .build();
        ConsumerGroup group = context.groupMetadataManager.consumerGroup(groupId);
        group.setMetadataRefreshDeadline(Long.MAX_VALUE, 11);

        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0),
                mkTopicAssignment(zarTopicId, 0)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(barTopicId, 0),
                mkTopicAssignment(fooTopicId, 1)
            ))
        )));

        // Member 1 rejoins with a new subscription list.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(memberId1)
            .withProtocols(GroupMetadataManagerTestContext.toConsumerProtocol(
                List.of(fooTopicName, barTopicName, zarTopicName),
                List.of()))
            .build();
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);

        // foo partition 0 retains epoch 10 (from original), zar partition 0 gets epoch 11 (newly assigned)
        ConsumerGroupMember expectedMember = new ConsumerGroupMember.Builder(memberId1)
            .setInstanceId(instanceId)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setRebalanceTimeoutMs(500)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setState(MemberState.STABLE)
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName, zarTopicName))
            .setAssignedPartitions(mkAssignmentWithEpochs(
                mkTopicAssignmentWithEpochs(fooTopicId, 10, 0),
                mkTopicAssignmentWithEpochs(zarTopicId, 11, 0)
            ))
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(request.sessionTimeoutMs())
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(
                        GroupMetadataManagerTestContext.toConsumerProtocol(
                            List.of(fooTopicName, barTopicName, zarTopicName),
                            List.of()
                        )
                    ))
            )
            .build();

        assertUnorderedRecordsEquals(
            List.of(
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, groupMetadataHash)),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
                        mkTopicAssignment(fooTopicId, 0),
                        mkTopicAssignment(zarTopicId, 0))),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, mkAssignment(
                        mkTopicAssignment(barTopicId, 0),
                        mkTopicAssignment(fooTopicId, 1)))
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds())),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember))
            ),
            joinResult.records
        );

        joinResult.appendFuture.complete(null);
        assertEquals(
            new JoinGroupResponseData()
                .setMemberId(memberId1)
                .setGenerationId(11)
                .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .setProtocolName("range"),
            joinResult.joinFuture.get()
        );
        context.assertSessionTimeout(groupId, memberId1, request.sessionTimeoutMs());
        context.assertSyncTimeout(groupId, memberId1, request.rebalanceTimeoutMs());
    }

    @Test
    public void testStaticMemberJoiningConsumerGroupWithUnknownInstanceId() throws Exception {
        String groupId = "group-id";
        String instanceId = "instance-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String fooTopicName = "foo";
        String barTopicName = "bar";

        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols =
            GroupMetadataManagerTestContext.toConsumerProtocol(
                List.of(fooTopicName, barTopicName),
                List.of(new TopicPartition(fooTopicName, 0), new TopicPartition(fooTopicName, 1))
            );
        // Set up a ConsumerGroup with no static member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(protocols))
                    )
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .build()))
            .build();

        // The member joins with an instance id.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(memberId1)
            .withGroupInstanceId(instanceId)
            .withProtocols(protocols)
            .build();

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupJoin(request));
    }

    @Test
    public void testStaticMemberJoiningConsumerGroupWithUnmatchedMemberId() throws Exception {
        String groupId = "group-id";
        String instanceId = "instance-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String fooTopicName = "foo";
        String barTopicName = "bar";

        JoinGroupRequestData.JoinGroupRequestProtocolCollection protocols =
            GroupMetadataManagerTestContext.toConsumerProtocol(
                List.of(fooTopicName, barTopicName),
                List.of(new TopicPartition(fooTopicName, 0), new TopicPartition(fooTopicName, 1))
            );
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setInstanceId(instanceId)
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(protocols))
                    )
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .build()))
            .build();

        // The member joins with the same instance id and a different member id.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(Uuid.randomUuid().toString())
            .withGroupInstanceId(instanceId)
            .withProtocols(protocols)
            .build();

        assertThrows(FencedInstanceIdException.class, () -> context.sendClassicGroupJoin(request));
    }

    @Test
    public void testReconciliationInJoiningConsumerGroupWithEagerProtocol() throws Exception {
        String groupId = "group-id";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Uuid zarTopicId = Uuid.randomUuid();
        String zarTopicName = "zar";

        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addTopic(barTopicId, barTopicName, 1)
            .addTopic(zarTopicId, zarTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);
        long zarTopicHash = computeTopicHash(zarTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setRebalanceTimeoutMs(500)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0),
                        mkTopicAssignment(barTopicId, 0)), 10))
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(
                                GroupMetadataManagerTestContext.toConsumerProtocol(
                                    List.of(fooTopicName, barTopicName),
                                    List.of(new TopicPartition(fooTopicName, 0), new TopicPartition(barTopicName, 0))
                                )
                            ))
                    )
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setRebalanceTimeoutMs(500)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 1)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0),
                    mkTopicAssignment(barTopicId, 0)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 1)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash
                ))))
            .build();
        ConsumerGroup group = context.groupMetadataManager.consumerGroup(groupId);
        group.setMetadataRefreshDeadline(Long.MAX_VALUE, 11);

        // Prepare the new target assignment.
        // Member 1 will need to revoke bar-0, and member 2 will need to revoke foo-1.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1),
                mkTopicAssignment(zarTopicId, 0)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(barTopicId, 0)
            ))
        )));

        // Member 1 rejoins with a new subscription list and an empty owned
        // partition, and transitions to UNRELEASED_PARTITIONS.
        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(memberId1)
            .withSessionTimeoutMs(5000)
            .withProtocols(GroupMetadataManagerTestContext.toConsumerProtocol(
                List.of(fooTopicName, barTopicName, zarTopicName),
                List.of()))
            .build();
        GroupMetadataManagerTestContext.JoinResult joinResult1 = context.sendClassicGroupJoin(request);

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setRebalanceTimeoutMs(500)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName, zarTopicName))
            .setAssignedPartitions(Map.of(
                fooTopicId, Map.of(0, 10),
                zarTopicId, Map.of(0, 11)))
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(request.sessionTimeoutMs())
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(
                        GroupMetadataManagerTestContext.toConsumerProtocol(
                            List.of(fooTopicName, barTopicName, zarTopicName),
                            List.of()
                        )
                    ))
            )
            .build();

        assertUnorderedRecordsEquals(
            List.of(
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash,
                    zarTopicName, zarTopicHash
                )))),

                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1),
                        mkTopicAssignment(zarTopicId, 0))),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, mkAssignment(
                        mkTopicAssignment(barTopicId, 0)))
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds())),

                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1))
            ),
            joinResult1.records
        );

        assertEquals(expectedMember1.state(), group.getOrMaybeCreateMember(memberId1, false).state());

        joinResult1.appendFuture.complete(null);
        JoinGroupResponseData joinResponse1 = joinResult1.joinFuture.get();
        assertEquals(
            new JoinGroupResponseData()
                .setMemberId(memberId1)
                .setGenerationId(11)
                .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .setProtocolName("range"),
            joinResponse1
        );
        context.assertSessionTimeout(groupId, memberId1, request.sessionTimeoutMs());
        context.assertSyncTimeout(groupId, memberId1, request.rebalanceTimeoutMs());

        // Member 1 sends sync request to get the assigned partitions.
        context.verifyClassicGroupSyncToConsumerGroup(
            groupId,
            joinResponse1.memberId(),
            joinResponse1.generationId(),
            joinResponse1.protocolName(),
            joinResponse1.protocolType(),
            List.of(
                new TopicPartition(fooTopicName, 0),
                new TopicPartition(zarTopicName, 0)
            )
        );

        // Member 2 heartbeats to confirm revoking foo-1.
        context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setTopicPartitions(List.of())
        );

        // Member 1 heartbeats to be notified to rejoin.
        assertEquals(
            Errors.REBALANCE_IN_PROGRESS.code(),
            context.sendClassicGroupHeartbeat(
                new HeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId1)
                    .setGenerationId(joinResponse1.generationId())
            ).response().errorCode()
        );
        context.assertJoinTimeout(groupId, memberId1, 500);

        // Member 1 rejoins to transition from UNRELEASED_PARTITIONS to STABLE.
        GroupMetadataManagerTestContext.JoinResult joinResult2 = context.sendClassicGroupJoin(request);
        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(expectedMember1)
            .setState(MemberState.STABLE)
            .setPreviousMemberEpoch(11)
            .setAssignedPartitions(
                mkAssignmentWithEpochs(
                    mkTopicAssignmentWithEpochs(fooTopicId, 10, 0),
                    mkTopicAssignmentWithEpochs(fooTopicId, 11, 1),
                    mkTopicAssignmentWithEpochs(zarTopicId, 11, 0)))
            .build();

        assertRecordsEquals(
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)),
            joinResult2.records
        );
        assertEquals(expectedMember2.state(), group.getOrMaybeCreateMember(memberId1, false).state());

        joinResult2.appendFuture.complete(null);
        context.assertNoJoinTimeout(groupId, memberId1);
        JoinGroupResponseData joinResponse2 = joinResult2.joinFuture.get();
        assertEquals(
            new JoinGroupResponseData()
                .setMemberId(memberId1)
                .setGenerationId(11)
                .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .setProtocolName("range"),
            joinResponse2
        );
        context.assertSessionTimeout(groupId, memberId1, request.sessionTimeoutMs());
        context.assertSyncTimeout(groupId, memberId1, request.rebalanceTimeoutMs());

        // Member 1 sends sync request to get the assigned partitions.
        context.verifyClassicGroupSyncToConsumerGroup(
            groupId,
            joinResponse2.memberId(),
            joinResponse2.generationId(),
            joinResponse2.protocolName(),
            joinResponse2.protocolType(),
            List.of(
                new TopicPartition(fooTopicName, 0),
                new TopicPartition(fooTopicName, 1),
                new TopicPartition(zarTopicName, 0)
            )
        );
    }

    @Test
    public void testReconciliationInJoiningConsumerGroupWithCooperativeProtocol() throws Exception {
        String groupId = "group-id";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Uuid zarTopicId = Uuid.randomUuid();
        String zarTopicName = "zar";

        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addTopic(barTopicId, barTopicName, 1)
            .addTopic(zarTopicId, zarTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);
        long zarTopicHash = computeTopicHash(zarTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setRebalanceTimeoutMs(500)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0),
                        mkTopicAssignment(barTopicId, 0)), 10))
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(
                                GroupMetadataManagerTestContext.toConsumerProtocol(
                                    List.of(fooTopicName, barTopicName),
                                    List.of(new TopicPartition(fooTopicName, 0), new TopicPartition(barTopicName, 0))
                                )
                            ))
                    )
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setRebalanceTimeoutMs(500)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of(fooTopicName, barTopicName))
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 1)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0),
                    mkTopicAssignment(barTopicId, 0)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 1)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash
                ))))
            .build();
        ConsumerGroup group = context.groupMetadataManager.consumerGroup(groupId);
        group.setMetadataRefreshDeadline(Long.MAX_VALUE, 11);

        // Prepare the new target assignment.
        // Member 1 will need to revoke bar-0, and member 2 will need to revoke foo-1.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1),
                mkTopicAssignment(zarTopicId, 0)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(barTopicId, 0)
            ))
        )));

        // Member 1 rejoins with a new subscription list and transitions to UNREVOKED_PARTITIONS.
        JoinGroupRequestData request1 = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(memberId1)
            .withSessionTimeoutMs(5000)
            .withProtocols(GroupMetadataManagerTestContext.toConsumerProtocol(
                List.of(fooTopicName, barTopicName, zarTopicName),
                List.of(new TopicPartition(fooTopicName, 0), new TopicPartition(barTopicName, 0))))
            .build();
        GroupMetadataManagerTestContext.JoinResult joinResult1 = context.sendClassicGroupJoin(request1);

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(10)
            .setRebalanceTimeoutMs(500)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setState(MemberState.UNREVOKED_PARTITIONS)
            .setSubscribedTopicNames(List.of(fooTopicName, barTopicName, zarTopicName))
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0)), 10))
            .setPartitionsPendingRevocation(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(barTopicId, 0)), 10))
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(request1.sessionTimeoutMs())
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(
                        GroupMetadataManagerTestContext.toConsumerProtocol(
                            List.of(fooTopicName, barTopicName, zarTopicName),
                            List.of(new TopicPartition(fooTopicName, 0), new TopicPartition(barTopicName, 0))
                        )
                    ))
            )
            .build();

        assertUnorderedRecordsEquals(
            List.of(
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash,
                    zarTopicName, zarTopicHash
                )))),

                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1),
                        mkTopicAssignment(zarTopicId, 0))),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, mkAssignment(
                        mkTopicAssignment(barTopicId, 0)))
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds())),

                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1))
            ),
            joinResult1.records
        );

        assertEquals(expectedMember1.state(), group.getOrMaybeCreateMember(memberId1, false).state());

        joinResult1.appendFuture.complete(null);
        JoinGroupResponseData joinResponse1 = joinResult1.joinFuture.get();
        assertEquals(
            new JoinGroupResponseData()
                .setMemberId(memberId1)
                .setGenerationId(10)
                .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .setProtocolName("range"),
            joinResponse1
        );
        context.assertSessionTimeout(groupId, memberId1, request1.sessionTimeoutMs());
        context.assertSyncTimeout(groupId, memberId1, request1.rebalanceTimeoutMs());

        // Member 1 sends sync request to get the assigned partitions.
        context.verifyClassicGroupSyncToConsumerGroup(
            groupId,
            joinResponse1.memberId(),
            joinResponse1.generationId(),
            joinResponse1.protocolName(),
            joinResponse1.protocolType(),
            List.of(new TopicPartition(fooTopicName, 0))
        );

        // Member 1 heartbeats to be notified to rejoin.
        assertEquals(
            Errors.REBALANCE_IN_PROGRESS.code(),
            context.sendClassicGroupHeartbeat(
                new HeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId1)
                    .setGenerationId(joinResponse1.generationId())
            ).response().errorCode()
        );
        context.assertJoinTimeout(groupId, memberId1, 500);

        // Member 1 rejoins to transition from UNREVOKED_PARTITIONS to UNRELEASED_PARTITIONS.
        JoinGroupRequestData request2 = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(memberId1)
            .withSessionTimeoutMs(5000)
            .withProtocols(GroupMetadataManagerTestContext.toConsumerProtocol(
                List.of(fooTopicName, barTopicName, zarTopicName),
                List.of(new TopicPartition(fooTopicName, 0))))
            .build();
        GroupMetadataManagerTestContext.JoinResult joinResult2 = context.sendClassicGroupJoin(request2);

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(expectedMember1)
            .setMemberEpoch(11)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setPartitionsPendingRevocation(Map.of())
            .setAssignedPartitions(Map.of(
                fooTopicId, Map.of(0, 10),
                zarTopicId, Map.of(0, 11)))
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(request2.sessionTimeoutMs())
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(
                        GroupMetadataManagerTestContext.toConsumerProtocol(
                            List.of(fooTopicName, barTopicName, zarTopicName),
                            List.of(new TopicPartition(fooTopicName, 0))
                        )
                    ))
            )
            .build();

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember2),
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
            ),
            joinResult2.records
        );
        assertEquals(expectedMember2.state(), group.getOrMaybeCreateMember(memberId1, false).state());

        joinResult2.appendFuture.complete(null);
        context.assertNoJoinTimeout(groupId, memberId1);
        JoinGroupResponseData joinResponse2 = joinResult2.joinFuture.get();
        assertEquals(
            new JoinGroupResponseData()
                .setMemberId(memberId1)
                .setGenerationId(11)
                .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .setProtocolName("range"),
            joinResponse2
        );
        context.assertSessionTimeout(groupId, memberId1, request2.sessionTimeoutMs());
        context.assertSyncTimeout(groupId, memberId1, request2.rebalanceTimeoutMs());

        // Member 1 sends sync request to get the assigned partitions.
        context.verifyClassicGroupSyncToConsumerGroup(
            groupId,
            joinResponse2.memberId(),
            joinResponse2.generationId(),
            joinResponse2.protocolName(),
            joinResponse2.protocolType(),
            List.of(
                new TopicPartition(fooTopicName, 0),
                new TopicPartition(zarTopicName, 0)
            )
        );

        // Member 2 heartbeats to confirm revoking foo-1.
        context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setTopicPartitions(List.of())
        );

        // Member 1 heartbeats to be notified to rejoin.
        assertEquals(
            Errors.REBALANCE_IN_PROGRESS.code(),
            context.sendClassicGroupHeartbeat(
                new HeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId1)
                    .setGenerationId(joinResponse2.generationId())
            ).response().errorCode()
        );
        context.assertJoinTimeout(groupId, memberId1, 500);

        // Member 1 rejoins to transition from UNRELEASED_PARTITIONS to STABLE.
        JoinGroupRequestData request3 = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(memberId1)
            .withSessionTimeoutMs(5000)
            .withProtocols(GroupMetadataManagerTestContext.toConsumerProtocol(
                List.of(fooTopicName, barTopicName, zarTopicName),
                List.of(new TopicPartition(fooTopicName, 0), new TopicPartition(zarTopicName, 0))))
            .build();
        GroupMetadataManagerTestContext.JoinResult joinResult3 = context.sendClassicGroupJoin(request3);

        ConsumerGroupMember expectedMember3 = new ConsumerGroupMember.Builder(expectedMember2)
            .setState(MemberState.STABLE)
            .setPreviousMemberEpoch(11)
            .setAssignedPartitions(
                mkAssignmentWithEpochs(
                    mkTopicAssignmentWithEpochs(fooTopicId, 10, 0),
                    mkTopicAssignmentWithEpochs(fooTopicId, 11, 1),
                    mkTopicAssignmentWithEpochs(zarTopicId, 11, 0)
                ))
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(request3.sessionTimeoutMs())
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(
                        GroupMetadataManagerTestContext.toConsumerProtocol(
                            List.of(fooTopicName, barTopicName, zarTopicName),
                            List.of(new TopicPartition(fooTopicName, 0), new TopicPartition(zarTopicName, 0))
                        )
                    ))
            )
            .build();

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember3),
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember3)
            ),
            joinResult3.records
        );
        assertEquals(expectedMember3.state(), group.getOrMaybeCreateMember(memberId1, false).state());

        joinResult3.appendFuture.complete(null);
        context.assertNoJoinTimeout(groupId, memberId1);
        JoinGroupResponseData joinResponse3 = joinResult3.joinFuture.get();
        assertEquals(
            new JoinGroupResponseData()
                .setMemberId(memberId1)
                .setGenerationId(11)
                .setProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .setProtocolName("range"),
            joinResponse3
        );
        context.assertSessionTimeout(groupId, memberId1, request3.sessionTimeoutMs());
        context.assertSyncTimeout(groupId, memberId1, request3.rebalanceTimeoutMs());

        // Member 1 sends sync request to get the assigned partitions.
        context.verifyClassicGroupSyncToConsumerGroup(
            groupId,
            joinResponse3.memberId(),
            joinResponse3.generationId(),
            joinResponse3.protocolName(),
            joinResponse3.protocolType(),
            List.of(
                new TopicPartition(fooTopicName, 0),
                new TopicPartition(fooTopicName, 1),
                new TopicPartition(zarTopicName, 0)
            )
        );
    }

    @Test
    public void testClassicGroupSyncToConsumerGroupWithAllConsumerProtocolVersions() throws Exception {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        for (short version = ConsumerProtocolAssignment.LOWEST_SUPPORTED_VERSION; version <= ConsumerProtocolAssignment.HIGHEST_SUPPORTED_VERSION; version++) {
            List<TopicPartition> topicPartitions = List.of(
                new TopicPartition(fooTopicName, 0),
                new TopicPartition(fooTopicName, 1),
                new TopicPartition(fooTopicName, 2),
                new TopicPartition(barTopicName, 0),
                new TopicPartition(barTopicName, 1)
            );

            List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
                new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                    .setName("range")
                    .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(
                        new ConsumerPartitionAssignor.Subscription(
                            List.of(fooTopicName, barTopicName),
                            null,
                            topicPartitions
                        ),
                        version
                    )))
            );

            ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
                .setState(MemberState.STABLE)
                .setMemberEpoch(10)
                .setPreviousMemberEpoch(9)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setClassicMemberMetadata(
                    new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                        .setSessionTimeoutMs(5000)
                        .setSupportedProtocols(protocols)
                )
                .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)), 10))
                .build();
            ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
                .setState(MemberState.STABLE)
                .setMemberEpoch(10)
                .setPreviousMemberEpoch(9)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)), 10))
                .build();

            // Consumer group with two members.
            // Member 1 uses the classic protocol and member 2 uses the consumer protocol.
            GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
                .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
                .withMetadataImage(new MetadataImageBuilder()
                    .addTopic(fooTopicId, fooTopicName, 6)
                    .addTopic(barTopicId, barTopicName, 3)
                    .addRacks()
                    .buildCoordinatorMetadataImage())
                .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                    .withMember(member1)
                    .withMember(member2)
                    .withAssignment(memberId1, mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2),
                        mkTopicAssignment(barTopicId, 0, 1)))
                    .withAssignment(memberId2, mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5),
                        mkTopicAssignment(barTopicId, 2)))
                    .withAssignmentEpoch(10))
                .build();

            context.verifyClassicGroupSyncToConsumerGroup(
                groupId,
                memberId1,
                10,
                "range",
                ConsumerProtocol.PROTOCOL_TYPE,
                topicPartitions,
                version
            );
        }
    }

    @Test
    public void testClassicGroupSyncToConsumerGroupWithUnknownMemberId() throws Exception {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();

        // Consumer group with a member that doesn't use the classic protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .build()))
            .build();

        // Request with unknown member id.
        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(Uuid.randomUuid().toString())
                .withGenerationId(10)
                .withProtocolName("range")
                .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .build())
        );

        // Request with unknown instance id.
        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(memberId)
                .withGroupInstanceId("unknown-instance-id")
                .withGenerationId(10)
                .withProtocolName("range")
                .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .build())
        );

        // Request with member id that doesn't use the classic protocol.
        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(memberId)
                .withGenerationId(10)
                .withProtocolName("range")
                .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .build())
        );
    }

    @Test
    public void testClassicGroupSyncToConsumerGroupWithFencedInstanceId() throws Exception {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();
        String instanceId = "instance-id";

        // Consumer group with a static member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setInstanceId(instanceId)
                    .build()))
            .build();

        assertThrows(FencedInstanceIdException.class, () -> context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(Uuid.randomUuid().toString())
                .withGroupInstanceId(instanceId)
                .withGenerationId(10)
                .withProtocolName("range")
                .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .build())
        );
    }

    @Test
    public void testClassicGroupSyncToConsumerGroupWithInconsistentGroupProtocol() throws Exception {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(
                    new ConsumerPartitionAssignor.Subscription(
                        List.of("foo"),
                        null,
                        List.of()
                    )
                )))
        );

        // Consumer group with a member using the classic protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(protocols)
                    )
                    .setMemberEpoch(10)
                    .build()))
            .build();

        // Request with unmatched protocol name.
        assertThrows(InconsistentGroupProtocolException.class, () -> context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(memberId)
                .withGenerationId(10)
                .withProtocolName("roundrobin")
                .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .build())
        );

        // Request with unmatched protocol type.
        assertThrows(InconsistentGroupProtocolException.class, () -> context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(memberId)
                .withGenerationId(10)
                .withProtocolName("range")
                .withProtocolType("connect")
                .build())
        );

        // Request with null protocol type or null protocol name won't fail the validation.
        context.verifyClassicGroupSyncToConsumerGroup(
            groupId,
            memberId,
            10,
            null,
            null,
            List.of()
        );
    }

    @Test
    public void testClassicGroupSyncToConsumerGroupWithIllegalGeneration() throws Exception {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(
                    new ConsumerPartitionAssignor.Subscription(
                        List.of("foo"),
                        null,
                        List.of()
                    )
                )))
        );

        // Consumer group with a member using the classic protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(protocols)
                    )
                    .setMemberEpoch(10)
                    .build()))
            .build();

        assertThrows(IllegalGenerationException.class, () -> context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(memberId)
                .withGenerationId(9)
                .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .withProtocolName("range")
                .build())
        );
    }

    @Test
    public void testClassicGroupSyncToConsumerGroupRebalanceInProgress() throws Exception {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(
                    new ConsumerPartitionAssignor.Subscription(
                        List.of("foo"),
                        null,
                        List.of()
                    )
                )))
        );

        // Consumer group with a member using the classic protocol.
        // The target assignment epoch is greater than the member epoch.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 11)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setRebalanceTimeoutMs(10000)
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(protocols)
                    )
                    .setMemberEpoch(10)
                    .build())
                .withAssignmentEpoch(11))
            .build();

        assertThrows(RebalanceInProgressException.class, () -> context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(memberId)
                .withGenerationId(10)
                .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
                .withProtocolName("range")
                .build())
        );
        context.assertJoinTimeout(groupId, memberId, 10000);
    }

    @Test
    public void testClassicGroupSyncToConsumerGroupDuringAssignmentDelay() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName),
                    null,
                    List.of(
                        new TopicPartition(fooTopicName, 0),
                        new TopicPartition(fooTopicName, 1),
                        new TopicPartition(fooTopicName, 2)
                    )
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocols)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(barTopicName))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with two members.
        // Member 1 uses the classic protocol and member 2 uses the consumer protocol.
        // Member 2 has just changed subscription from foo to bar and the new assignment has not
        // been computed yet.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 11)
                .withMember(member1)
                .withMember(member2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        // Member 1 is not told to rebalance yet.
        assertDoesNotThrow(() -> {
            GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(
                new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                    .withGroupId(groupId)
                    .withMemberId(memberId1)
                    .withGenerationId(10)
                    .build()
            );
            syncResult.appendFuture.complete(null);
        });

        // Member 2 heartbeats and triggers a new assignment.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(barTopicId, 0, 1, 2)
            ))
        )));
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result2 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10));
        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(0, 1, 2))
                    ))),
            result2.response()
        );

        // Member 1 is told to rebalance now that the new assignment is available.
        assertThrows(RebalanceInProgressException.class, () -> context.sendClassicGroupSync(
            new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(memberId1)
                .withGenerationId(10)
                .build())
        );
    }

    @Test
    public void testClassicGroupHeartbeatToConsumerGroupMaintainsSession() throws Exception {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();
        int sessionTimeout = 5000;

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(
                    new ConsumerPartitionAssignor.Subscription(
                        List.of("foo"),
                        null,
                        List.of()
                    )
                )))
        );

        // Consumer group with a member using the classic protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
            .withMember(new ConsumerGroupMember.Builder(memberId)
                .setClassicMemberMetadata(
                    new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                        .setSessionTimeoutMs(sessionTimeout)
                        .setSupportedProtocols(protocols)
                )
                .setMemberEpoch(10)
                .build()))
            .build();

        // Heartbeat to schedule the session timeout.
        HeartbeatRequestData request = new HeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId)
            .setGenerationId(10);
        context.sendClassicGroupHeartbeat(request);
        context.assertSessionTimeout(groupId, memberId, sessionTimeout);

        // Advance clock by 1/2 of session timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(sessionTimeout / 2));

        HeartbeatResponseData heartbeatResponse = context.sendClassicGroupHeartbeat(request).response();
        assertEquals(Errors.NONE.code(), heartbeatResponse.errorCode());
        context.assertSessionTimeout(groupId, memberId, sessionTimeout);

        // Advance clock by 1/2 of session timeout.
        GroupMetadataManagerTestContext.assertNoOrEmptyResult(context.sleep(sessionTimeout / 2));

        heartbeatResponse = context.sendClassicGroupHeartbeat(request).response();
        assertEquals(Errors.NONE.code(), heartbeatResponse.errorCode());
        context.assertSessionTimeout(groupId, memberId, sessionTimeout);
    }

    @Test
    public void testClassicGroupHeartbeatToConsumerGroupRebalanceInProgress() throws Exception {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        Uuid barTopicId = Uuid.randomUuid();
        int sessionTimeout = 5000;
        int rebalanceTimeout = 10000;

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(
                    new ConsumerPartitionAssignor.Subscription(
                        List.of("foo"),
                        null,
                        List.of()
                    )
                )))
        );

        // Member 1 has a member epoch smaller than the target assignment epoch.
        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setRebalanceTimeoutMs(rebalanceTimeout)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(sessionTimeout)
                    .setSupportedProtocols(protocols)
            )
            .setMemberEpoch(9)
            .build();

        // Member 2 has unrevoked partition.
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.UNREVOKED_PARTITIONS)
            .setRebalanceTimeoutMs(rebalanceTimeout)
            .setPartitionsPendingRevocation(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0)), 10))
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(sessionTimeout)
                    .setSupportedProtocols(protocols)
            )
            .setMemberEpoch(10)
            .build();

        // Member 3 is in UNRELEASED_PARTITIONS and all the partitions in its target assignment are free.
        ConsumerGroupMember member3 = new ConsumerGroupMember.Builder(memberId3)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setRebalanceTimeoutMs(rebalanceTimeout)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(barTopicId, 0)), 10))
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(sessionTimeout)
                    .setSupportedProtocols(protocols)
            )
            .setMemberEpoch(10)
            .build();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withMember(member3)
                .withAssignment(memberId3, mkAssignment(mkTopicAssignment(barTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10))
            .build();

        List.of(memberId1, memberId2, memberId3).forEach(memberId -> {
            CoordinatorResult<HeartbeatResponseData, CoordinatorRecord> heartbeatResult = context.sendClassicGroupHeartbeat(
                new HeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setGenerationId(memberId.equals(memberId1) ? 9 : 10)
            );
            assertEquals(List.of(), heartbeatResult.records());
            assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), heartbeatResult.response().errorCode());
            context.assertSessionTimeout(groupId, memberId, sessionTimeout);
            context.assertJoinTimeout(groupId, memberId, rebalanceTimeout);
        });
    }

    @Test
    public void testClassicGroupHeartbeatToConsumerWithUnknownMember() {
        String groupId = "group-id";

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10))
            .build();

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId("unknown-member-id")
                .setGenerationId(10)
        ));

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId("unknown-member-id")
                .setGroupInstanceId("unknown-instance-id")
                .setGenerationId(10)
        ));
    }

    @Test
    public void testClassicGroupHeartbeatToConsumerWithFencedInstanceId() {
        String groupId = "group-id";
        String memberId = "member-id";
        String instanceId = "instance-id";

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setInstanceId(instanceId)
                    .setMemberEpoch(10)
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(List.of())
                    )
                    .build()))
            .build();

        assertThrows(FencedInstanceIdException.class, () -> context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId("unknown-member-id")
                .setGroupInstanceId(instanceId)
                .setGenerationId(10)
        ));
    }

    @Test
    public void testClassicGroupHeartbeatToConsumerWithIllegalGenerationId() {
        String groupId = "group-id";
        String memberId = "member-id";

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setMemberEpoch(10)
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(List.of())
                    )
                    .build()))
            .build();

        assertThrows(IllegalGenerationException.class, () -> context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setGenerationId(9)
        ));
    }

    @Test
    public void testClassicGroupHeartbeatToConsumerWithMemberNotUsingClassicProtocol() {
        String groupId = "group-id";
        String memberId = "member-id";

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setMemberEpoch(10)
                    .build()))
            .build();

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setGenerationId(10)
        ));
    }

    @Test
    public void testClassicGroupHeartbeatToConsumerGroupDuringAssignmentDelay() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName),
                    null,
                    List.of(
                        new TopicPartition(fooTopicName, 0),
                        new TopicPartition(fooTopicName, 1),
                        new TopicPartition(fooTopicName, 2)
                    )
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocols)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(barTopicName))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with two members.
        // Member 1 uses the classic protocol and member 2 uses the consumer protocol.
        // Member 2 has just changed subscription from foo to bar and the new assignment has not
        // been computed yet.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 11)
                .withMember(member1)
                .withMember(member2)
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        // Member 1 is not told to rebalance yet.
        HeartbeatResponseData heartbeatResponse1 = context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setGenerationId(10)
        ).response();
        assertEquals(Errors.NONE.code(), heartbeatResponse1.errorCode());

        // Member 2 heartbeats and triggers a new assignment.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(barTopicId, 0, 1, 2)
            ))
        )));
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10));
        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(0, 1, 2))
                    ))),
            result.response()
        );

        // Member 1 is told to rebalance now that the new assignment is available.
        HeartbeatResponseData heartbeatResponse2 = context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setGenerationId(10)
        ).response();
        assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), heartbeatResponse2.errorCode());
    }

    @Test
    public void testConsumerGroupMemberUsingClassicProtocolFencedWhenSessionTimeout() {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();
        int sessionTimeout = 5000;

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(
                    new ConsumerPartitionAssignor.Subscription(
                        List.of("foo"),
                        null,
                        List.of()
                    )
                )))
        );

        // Consumer group with a member using the classic protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(sessionTimeout)
                            .setSupportedProtocols(protocols)
                    )
                    .setMemberEpoch(10)
                    .build()))
            .build();

        // Heartbeat to schedule the session timeout.
        HeartbeatRequestData request = new HeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId)
            .setGenerationId(10);
        context.sendClassicGroupHeartbeat(request);
        context.assertSessionTimeout(groupId, memberId, sessionTimeout);

        // Advance clock by session timeout + 1.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(sessionTimeout + 1);

        // The member is fenced from the group.
        assertEquals(1, timeouts.size());
        ExpiredTimeout<CoordinatorRecord> timeout = timeouts.get(0);
        assertEquals(groupSessionTimeoutKey(groupId, memberId), timeout.key());
        assertRecordsEquals(
            List.of(
                // The member is removed.
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId),
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId),

                // The group epoch is bumped.
                GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, 0)
            ),
            timeout.result().records()
        );
    }

    @Test
    public void testConsumerGroupMemberUsingClassicProtocolFencedWhenJoinTimeout() {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();
        int rebalanceTimeout = 500;

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(
                    new ConsumerPartitionAssignor.Subscription(
                        List.of("foo"),
                        null,
                        List.of()
                    )
                )))
        );

        // Consumer group with a member using the classic protocol whose member epoch is smaller than the target assignment epoch.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .setRebalanceTimeoutMs(rebalanceTimeout)
                    .setClassicMemberMetadata(
                        new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                            .setSessionTimeoutMs(5000)
                            .setSupportedProtocols(protocols)
                    )
                    .setMemberEpoch(9)
                    .build())
                .withAssignmentEpoch(10))
            .build();

        // Heartbeat to schedule the join timeout.
        HeartbeatRequestData request = new HeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId)
            .setGenerationId(9);
        assertEquals(
            Errors.REBALANCE_IN_PROGRESS.code(),
            context.sendClassicGroupHeartbeat(request).response().errorCode()
        );
        context.assertSessionTimeout(groupId, memberId, 5000);
        context.assertJoinTimeout(groupId, memberId, rebalanceTimeout);

        // Advance clock by rebalance timeout + 1.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(rebalanceTimeout + 1);

        // The member is fenced from the group.
        assertEquals(1, timeouts.size());
        ExpiredTimeout<CoordinatorRecord> timeout = timeouts.get(0);
        assertEquals(consumerGroupJoinKey(groupId, memberId), timeout.key());
        assertRecordsEquals(
            List.of(
                // The member is removed.
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId),
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId),

                // The group epoch is bumped.
                GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, 0)
            ),
            timeout.result().records()
        );
    }

    @Test
    public void testConsumerGroupMemberUsingClassicProtocolBatchLeaveGroup() {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();
        String instanceId2 = "instance-id-2";
        String instanceId3 = "instance-id-3";

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocol1 = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(new TopicPartition(fooTopicName, 0))
                ))))
        );
        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocol2 = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(new TopicPartition(fooTopicName, 1))
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocol1)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0)), 11))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setInstanceId(instanceId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(9)
            .setPreviousMemberEpoch(8)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocol2)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 1)), 10))
            .build();
        ConsumerGroupMember member3 = new ConsumerGroupMember.Builder(memberId3)
            .setInstanceId(instanceId3)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(barTopicId, 0)), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addTopic(barTopicId, barTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with three members.
        // Dynamic member 1 uses the classic protocol.
        // Static member 2 uses the classic protocol.
        // Static member 3 uses the consumer protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withMember(member3)
                .withAssignment(memberId1, mkAssignment(mkTopicAssignment(fooTopicId, 0)))
                .withAssignment(memberId2, mkAssignment(mkTopicAssignment(fooTopicId, 1)))
                .withAssignment(memberId3, mkAssignment(mkTopicAssignment(barTopicId, 0)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();
        context.groupMetadataManager.consumerGroup(groupId).setMetadataRefreshDeadline(Long.MAX_VALUE, 10);

        // Member 1 joins to schedule the sync timeout and the heartbeat timeout.
        context.sendClassicGroupJoin(
            new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
                .withGroupId(groupId)
                .withMemberId(memberId1)
                .withRebalanceTimeoutMs(member1.rebalanceTimeoutMs())
                .withSessionTimeoutMs(member1.classicMemberMetadata().get().sessionTimeoutMs())
                .withProtocols(GroupMetadataManagerTestContext.toConsumerProtocol(
                    List.of(fooTopicName, barTopicName),
                    List.of(new TopicPartition(fooTopicName, 0))))
                .build()
        ).appendFuture.complete(null);
        context.assertSyncTimeout(groupId, memberId1, member1.rebalanceTimeoutMs());
        context.assertSessionTimeout(groupId, memberId1, member1.classicMemberMetadata().get().sessionTimeoutMs());

        // Member 2 heartbeats to schedule the join timeout and the heartbeat timeout.
        context.sendClassicGroupHeartbeat(
            new HeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setGenerationId(9)
        );
        context.assertJoinTimeout(groupId, memberId2, member2.rebalanceTimeoutMs());
        context.assertSessionTimeout(groupId, memberId2, member2.classicMemberMetadata().get().sessionTimeoutMs());

        // Member 1, member 2 and member 3 leave the group.
        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    // Valid member id.
                    new MemberIdentity()
                        .setMemberId(memberId1)
                        .setGroupInstanceId(null),
                    new MemberIdentity()
                        .setMemberId(UNKNOWN_MEMBER_ID)
                        .setGroupInstanceId(instanceId2),
                    // Member that doesn't use the classic protocol.
                    new MemberIdentity()
                        .setMemberId(memberId3)
                        .setGroupInstanceId(instanceId3),
                    // Unknown member id.
                    new MemberIdentity()
                        .setMemberId("unknown-member-id")
                        .setGroupInstanceId(null),
                    new MemberIdentity()
                        .setMemberId(UNKNOWN_MEMBER_ID)
                        .setGroupInstanceId("unknown-instance-id"),
                    // Fenced instance id.
                    new MemberIdentity()
                        .setMemberId("unknown-member-id")
                        .setGroupInstanceId(instanceId3)
                ))
        );

        assertEquals(
            new LeaveGroupResponseData()
                .setMembers(List.of(
                    new LeaveGroupResponseData.MemberResponse()
                        .setGroupInstanceId(null)
                        .setMemberId(memberId1),
                    new LeaveGroupResponseData.MemberResponse()
                        .setGroupInstanceId(instanceId2)
                        .setMemberId(UNKNOWN_MEMBER_ID),
                    new LeaveGroupResponseData.MemberResponse()
                        .setGroupInstanceId(instanceId3)
                        .setMemberId(memberId3),
                    new LeaveGroupResponseData.MemberResponse()
                        .setGroupInstanceId(null)
                        .setMemberId("unknown-member-id")
                        .setErrorCode(Errors.UNKNOWN_MEMBER_ID.code()),
                    new LeaveGroupResponseData.MemberResponse()
                        .setGroupInstanceId("unknown-instance-id")
                        .setErrorCode(Errors.UNKNOWN_MEMBER_ID.code()),
                    new LeaveGroupResponseData.MemberResponse()
                        .setMemberId("unknown-member-id")
                        .setGroupInstanceId(instanceId3)
                        .setErrorCode(Errors.FENCED_INSTANCE_ID.code())
                )),
            leaveResult.response()
        );

        List<List<CoordinatorRecord>> expectedRecords = List.of(
            List.of(
                // Remove member 1
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
                // Remove member 2.
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2),
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2),
                // Remove member 3.
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId3),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId3),
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId3)
            ),
            // Bump the group epoch.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, 0))
        );
        assertUnorderedRecordsEquals(expectedRecords, leaveResult.records());

        context.assertNoSessionTimeout(groupId, memberId1);
        context.assertNoSyncTimeout(groupId, memberId1);
        context.assertNoSessionTimeout(groupId, memberId2);
        context.assertNoJoinTimeout(groupId, memberId2);
    }

    @Test
    public void testConsumerGroupMemberUsingClassicProtocolBatchLeaveGroupUpdatingSubscriptionMetadata() {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocol = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(new TopicPartition(fooTopicName, 0))
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocol)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0)), 11))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(barTopicId, 0)), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addTopic(barTopicId, barTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);

        // Consumer group with two members.
        // Member 1 uses the classic protocol and member 2 uses the consumer protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withAssignment(memberId1, mkAssignment(mkTopicAssignment(fooTopicId, 0)))
                .withAssignment(memberId2, mkAssignment(mkTopicAssignment(barTopicId, 0)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash
                ))))
            .build();
        context.groupMetadataManager.consumerGroup(groupId).setMetadataRefreshDeadline(Long.MAX_VALUE, 10);

        // Member 1 leaves the group.
        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    new MemberIdentity()
                        .setMemberId(memberId1)
                ))
        );

        assertEquals(
            new LeaveGroupResponseData()
                .setMembers(List.of(
                    new LeaveGroupResponseData.MemberResponse()
                        .setGroupInstanceId(null)
                        .setMemberId(memberId1)
                )),
            leaveResult.response()
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            // Remove member 1
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
            // Bump the group epoch.
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, fooTopicHash
            )))
        );
        assertEquals(expectedRecords, leaveResult.records());
    }

    @Test
    public void testClassicGroupLeaveToConsumerGroupWithoutValidLeaveGroupMember() {
        String groupId = "group-id";
        String memberId = Uuid.randomUuid().toString();

        // Consumer group.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId)
                    .build()))
            .build();

        // Send leave request without valid member.
        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    new MemberIdentity()
                        .setMemberId("unknown-member-id")
                ))
        );

        assertEquals(
            new LeaveGroupResponseData()
                .setMembers(List.of(
                    new LeaveGroupResponseData.MemberResponse()
                        .setGroupInstanceId(null)
                        .setMemberId("unknown-member-id")
                        .setErrorCode(Errors.UNKNOWN_MEMBER_ID.code())
                )),
            leaveResult.response()
        );

        assertEquals(List.of(), leaveResult.records());
    }

    @Test
    public void testLastConsumerProtocolMemberLeavingConsumerGroupByAdminApi() {
        String groupId = "group-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();
        String memberId4 = Uuid.randomUuid().toString();
        String instanceId2 = "instance-id-2";
        String instanceId4 = "instance-id-4";

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocol1 = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(new TopicPartition(fooTopicName, 0))
                ))))
        );
        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocol2 = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                    List.of(fooTopicName, barTopicName),
                    null,
                    List.of(new TopicPartition(fooTopicName, 1))
                ))))
        );

        ConsumerGroupMember member1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocol1)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0)), 11))
            .build();
        ConsumerGroupMember member2 = new ConsumerGroupMember.Builder(memberId2)
            .setInstanceId(instanceId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(5000)
                    .setSupportedProtocols(protocol2)
            )
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 1)), 10))
            .build();
        ConsumerGroupMember member3 = new ConsumerGroupMember.Builder(memberId3)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(barTopicId, 0)), 10))
            .build();
        ConsumerGroupMember member4 = new ConsumerGroupMember.Builder(memberId4)
            .setInstanceId(instanceId4)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setServerAssignorName("range")
            .setRebalanceTimeoutMs(45000)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(barTopicId, 1)), 10))
            .build();

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .addTopic(barTopicId, barTopicName, 2)
            .addRacks()
            .buildCoordinatorMetadataImage();

        // Consumer group with four members.
        // Dynamic member 1 uses the classic protocol.
        // Static member 2 uses the classic protocol.
        // Dynamic member 3 uses the consumer protocol.
        // Static member 4 uses the consumer protocol.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(member1)
                .withMember(member2)
                .withMember(member3)
                .withMember(member4)
                .withAssignment(memberId1, mkAssignment(mkTopicAssignment(fooTopicId, 0)))
                .withAssignment(memberId2, mkAssignment(mkTopicAssignment(fooTopicId, 1)))
                .withAssignment(memberId3, mkAssignment(mkTopicAssignment(barTopicId, 0)))
                .withAssignment(memberId4, mkAssignment(mkTopicAssignment(barTopicId, 1)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                ))))
            .build();

        ConsumerGroup consumerGroup = context.groupMetadataManager.consumerGroup(groupId);

        // Member 2, member 3 and member 4 leave the group, triggering the downgrade.
        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> leaveResult = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId("group-id")
                .setMembers(List.of(
                    // Static classic member 2.
                    new MemberIdentity()
                        .setMemberId(memberId2)
                        .setGroupInstanceId(null),
                    // Dynamic consumer member 3.
                    new MemberIdentity()
                        .setMemberId(memberId3)
                        .setGroupInstanceId(null),
                    // Static consumer member 4, by group instance id.
                    new MemberIdentity()
                        .setMemberId(UNKNOWN_MEMBER_ID)
                        .setGroupInstanceId(instanceId4)
                ))
        );

        assertEquals(
            new LeaveGroupResponseData()
                .setMembers(List.of(
                    new LeaveGroupResponseData.MemberResponse()
                        .setGroupInstanceId(null)
                        .setMemberId(memberId2),
                    new LeaveGroupResponseData.MemberResponse()
                        .setGroupInstanceId(null)
                        .setMemberId(memberId3),
                    new LeaveGroupResponseData.MemberResponse()
                        .setGroupInstanceId(instanceId4)
                        .setMemberId(UNKNOWN_MEMBER_ID)
                )),
            leaveResult.response()
        );


        byte[] assignment = Utils.toArray(ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(List.of(
            new TopicPartition(fooTopicName, 0)
        ))));
        Map<String, byte[]> assignments = Map.of(memberId1, assignment);

        ClassicGroup expectedClassicGroup = new ClassicGroup(
            new LogContext(),
            groupId,
            STABLE,
            context.time,
            10,
            Optional.of(ConsumerProtocol.PROTOCOL_TYPE),
            Optional.of("range"),
            Optional.of(memberId1),
            Optional.of(context.time.milliseconds())
        );
        expectedClassicGroup.add(
            new ClassicGroupMember(
                memberId1,
                Optional.ofNullable(member1.instanceId()),
                member1.clientId(),
                member1.clientHost(),
                member1.rebalanceTimeoutMs(),
                member1.classicProtocolSessionTimeout().get(),
                ConsumerProtocol.PROTOCOL_TYPE,
                member1.supportedJoinGroupRequestProtocols(),
                assignment
            )
        );

        assertUnorderedRecordsEquals(
            List.of(
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2),
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId3),
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId4)
                ),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId3),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId4)
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataTombstoneRecord(groupId)),
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2),
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId3),
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId4)
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord(groupId)),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochTombstoneRecord(groupId)),
                List.of(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(expectedClassicGroup, assignments))
            ),
            leaveResult.records()
        );

        // The new classic member 1 has a heartbeat timeout.
        ScheduledTimeout<CoordinatorRecord> heartbeatTimeout = context.timer.timeout(
            classicGroupHeartbeatKey(groupId, memberId1)
        );
        assertNotNull(heartbeatTimeout);
        // The new rebalance has a groupJoin timeout.
        ScheduledTimeout<CoordinatorRecord> groupJoinTimeout = context.timer.timeout(
            classicGroupJoinKey(groupId)
        );
        assertNotNull(groupJoinTimeout);

        // A new rebalance is triggered.
        ClassicGroup classicGroup = context.groupMetadataManager.getOrMaybeCreateClassicGroup(groupId, false);
        assertTrue(classicGroup.isInState(PREPARING_REBALANCE));

        // Simulate a failed write to the log.
        context.rollback();

        // The group is reverted back to the consumer group.
        assertEquals(consumerGroup, context.groupMetadataManager.consumerGroup(groupId));
    }

    @Test
    public void testNoConversionWhenSizeExceedsClassicMaxGroupSize() throws Exception {
        String groupId = "group-id";
        String nonClassicMemberId = "1";

        List<ConsumerGroupMemberMetadataValue.ClassicProtocol> protocols = List.of(
            new ConsumerGroupMemberMetadataValue.ClassicProtocol()
                .setName("range")
                .setMetadata(new byte[0])
        );

        ConsumerGroupMember member = new ConsumerGroupMember.Builder(nonClassicMemberId).build();
        ConsumerGroupMember classicMember1 = new ConsumerGroupMember.Builder("2")
            .setClassicMemberMetadata(new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata().setSupportedProtocols(protocols))
            .build();
        ConsumerGroupMember classicMember2 = new ConsumerGroupMember.Builder("3")
            .setClassicMemberMetadata(new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata().setSupportedProtocols(protocols))
            .build();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.GROUP_MAX_SIZE_CONFIG, 1)
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_MIGRATION_POLICY_CONFIG, ConsumerGroupMigrationPolicy.DOWNGRADE.toString())
            .withConsumerGroup(
                new ConsumerGroupBuilder(groupId, 10)
                    .withMember(member)
                    .withMember(classicMember1)
                    .withMember(classicMember2)
            )
            .build();

        assertEquals(Group.GroupType.CONSUMER, context.groupMetadataManager.group(groupId).type());

        context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(nonClassicMemberId)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH)
                .setRebalanceTimeoutMs(5000)
        );

        assertEquals(Group.GroupType.CONSUMER, context.groupMetadataManager.group(groupId).type());
    }

    @Test
    public void testShareGroupDescribeRequest() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();

        // GroupId is not required
        List<ShareGroupDescribeResponseData.DescribedGroup> groups = context.sendShareGroupDescribe(List.of());
        assertEquals(0, groups.size());

        // Group id not found
        groups = context.sendShareGroupDescribe(List.of("unknown-group"));
        assertEquals(1, groups.size());
        assertEquals(Errors.GROUP_ID_NOT_FOUND.code(), groups.get(0).errorCode());
    }

    @Test
    public void testShareGroupDescribeNoErrors() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("share-range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of()
        ));

        List<String> groupIds = List.of("group-id-1", "group-id-2");
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupIds.get(0), 100, 0));
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupIds.get(1), 15, 0));

        Uuid topicId = Uuid.randomUuid();
        String topicName = "foo";
        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(topicId, topicName, 1)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(image.emptyDelta(), image);

        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupIds.get(1))
                .setMemberId(Uuid.randomUuid().toString())
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of(topicName)));

        // Verify that a member id was generated for the new member.
        String memberId = result.response().getKey().memberId();
        assertNotNull(memberId);
        context.commit();

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(
                topicId,
                Set.of(0)
            ),
            groupIds.get(1),
            16,
            true
        );

        List<ShareGroupDescribeResponseData.DescribedGroup> expected = List.of(
            new ShareGroupDescribeResponseData.DescribedGroup()
                .setGroupEpoch(100)
                .setAssignmentEpoch(1)
                .setGroupId(groupIds.get(0))
                .setGroupState(ShareGroup.ShareGroupState.EMPTY.toString())
                .setAssignorName("share-range"),
            new ShareGroupDescribeResponseData.DescribedGroup()
                .setGroupEpoch(16)
                .setAssignmentEpoch(16)
                .setGroupId(groupIds.get(1))
                .setMembers(List.of(
                    new ShareGroupMember.Builder(memberId)
                        .setMemberEpoch(16)
                        .setClientId("client")
                        .setClientHost("localhost/127.0.0.1")
                        .setSubscribedTopicNames(List.of(topicName))
                        .build()
                        .asShareGroupDescribeMember(
                            new MetadataImageBuilder().buildCoordinatorMetadataImage()
                        )
                ))
                .setGroupState(ShareGroup.ShareGroupState.STABLE.toString())
                .setAssignorName("share-range")
        );
        List<ShareGroupDescribeResponseData.DescribedGroup> actual = context.sendShareGroupDescribe(groupIds);

        assertEquals(expected, actual);
    }

    @Test
    public void testShareGroupMemberIdGeneration() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of()
        ));

        String memberId = Uuid.randomUuid().toString();
        Uuid topicId1 = Uuid.randomUuid();
        String topicName1 = "foo";
        Uuid topicId2 = Uuid.randomUuid();
        String topicName2 = "bar";
        String groupId = "group-foo";

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(topicId1, topicName1, 1)
            .addTopic(topicId2, topicName2, 1)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(image.emptyDelta(), image);

        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of(topicName1, topicName2)));

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(
                topicId1,
                Set.of(0),
                topicId2,
                Set.of(0)
            ),
            groupId,
            2,
            true
        );

        assertEquals(
            memberId,
            result.response().getKey().memberId(),
            "MemberId should remain unchanged, as the server does not generate a new one since the consumer generates its own."
        );

        // The response should get a bumped epoch and should not
        // contain any assignment because we did not provide
        // topics metadata.
        assertEquals(
            new ShareGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ShareGroupHeartbeatResponseData.Assignment()),
            result.response().getKey()
        );
    }

    @Test
    public void testShareGroupUnknownGroupId() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();

        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        assertThrows(GroupIdNotFoundException.class, () ->
            context.shareGroupHeartbeat(
                new ShareGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(100) // Epoch must be > 0.
                    .setSubscribedTopicNames(List.of("foo", "bar"))));
    }

    @Test
    public void testShareGroupUnknownMemberIdJoins() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(new NoOpPartitionAssignor())
            .build();

        Uuid topicId1 = Uuid.randomUuid();
        String topicName1 = "foo";
        Uuid topicId2 = Uuid.randomUuid();
        String topicName2 = "bar";

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(topicId1, topicName1, 1)
            .addTopic(topicId2, topicName2, 1)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(image.emptyDelta(), image);

        // A first member joins to create the group.
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of(topicName1, topicName2)));

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(
                topicId1,
                Set.of(0),
                topicId2,
                Set.of(0)
            ),
            groupId,
            2,
            true
        );

        // The second member is rejected because the member id is unknown and
        // the member epoch is not zero.
        assertThrows(UnknownMemberIdException.class, () ->
            context.shareGroupHeartbeat(
                new ShareGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(Uuid.randomUuid().toString())
                    .setMemberEpoch(1)
                    .setSubscribedTopicNames(List.of("foo", "bar"))));
    }

    @Test
    public void testShareGroupMemberJoinsEmptyGroupWithAssignments() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addTopic(barTopicId, barTopicName, 3)
                .addRacks()
                .buildCoordinatorMetadataImage())
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            )))
        ));

        assertThrows(GroupIdNotFoundException.class, () ->
            context.groupMetadataManager.shareGroup(groupId));

        MetadataImage image = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .build();
        
        CoordinatorMetadataImage coordinatorMetadataImage = new KRaftCoordinatorMetadataImage(image);

        MetadataDelta delta = new MetadataDelta.Builder()
            .setImage(image)
            .build();

        context.groupMetadataManager.onMetadataUpdate(new KRaftCoordinatorMetadataDelta(delta), coordinatorMetadataImage);

        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of("foo", "bar")));

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(
                fooTopicId,
                Set.of(0, 1, 2, 3, 4, 5),
                barTopicId,
                Set.of(0, 1, 2)
            ),
            groupId,
            2,
            true
        );

        assertResponseEquals(
            new ShareGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ShareGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ShareGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5)),
                        new ShareGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(0, 1, 2))
                    ))),
            result.response().getKey()
        );

        ShareGroupMember expectedMember = new ShareGroupMember.Builder(memberId)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setSubscribedTopicNames(List.of("foo", "bar"))
            .setAssignedPartitions(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            ))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newShareGroupMemberSubscriptionRecord(groupId, expectedMember),
            GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 2, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, coordinatorMetadataImage),
                barTopicName, computeTopicHash(barTopicName, coordinatorMetadataImage)
            ))),
            GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentRecord(groupId, memberId, mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            )),
            GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newShareGroupCurrentAssignmentRecord(groupId, expectedMember),
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(groupId, mkShareGroupStateMap(List.of(
                    mkShareGroupStateMetadataEntry(fooTopicId, fooTopicName, List.of(0, 1, 2, 3, 4, 5)),
                    mkShareGroupStateMetadataEntry(barTopicId, barTopicName, List.of(0, 1, 2))
                )),
                Map.of(),
                Map.of()
            )
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    private Map<Uuid, InitMapValue> mkShareGroupStateMap(List<Map.Entry<Uuid, Map.Entry<String, Set<Integer>>>> entries) {
        Map<Uuid, InitMapValue> map = new HashMap<>();
        for (Map.Entry<Uuid, Map.Entry<String, Set<Integer>>> entry : entries) {
            map.put(entry.getKey(), new InitMapValue(entry.getValue().getKey(), entry.getValue().getValue(), 1));
        }
        return map;
    }

    private Map.Entry<Uuid, Map.Entry<String, Set<Integer>>> mkShareGroupStateMetadataEntry(Uuid topicId, String topicName, List<Integer> partitions) {
        return Map.entry(
            topicId,
            Map.entry(topicName, new LinkedHashSet<>(partitions))
        );
    }

    @Test
    public void testShareGroupLeavingMemberBumpsGroupEpoch() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Uuid zarTopicId = Uuid.randomUuid();
        String zarTopicName = "zar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addTopic(zarTopicId, zarTopicName, 1)
            .addRacks()
            .buildCoordinatorMetadataImage();

        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(metadataImage)
            .withShareGroup(new ShareGroupBuilder(groupId, 10)
                .withMember(new ShareGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId("client")
                    .setClientHost("localhost/127.0.0.1")
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setAssignedPartitions(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2),
                        mkTopicAssignment(barTopicId, 0, 1)))
                    .build())
                .withMember(new ShareGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId("client")
                    .setClientHost("localhost/127.0.0.1")
                    // Use zar only here to ensure that metadata needs to be recomputed.
                    .setSubscribedTopicNames(List.of("foo", "bar", "zar"))
                    .setAssignedPartitions(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5),
                        mkTopicAssignment(barTopicId, 2)))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0, 1)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage),
                    zarTopicName, computeTopicHash(zarTopicName, metadataImage)
                ))))
            .build();

        // Member 2 leaves the consumer group.
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH)
                .setSubscribedTopicNames(List.of(fooTopicName, barTopicName)));

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(),
            "",
            -1,
            false
        );

        assertResponseEquals(
            new ShareGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH),
            result.response().getKey()
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newShareGroupCurrentAssignmentTombstoneRecord(groupId, memberId2),
            GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentTombstoneRecord(groupId, memberId2),
            GroupCoordinatorRecordHelpers.newShareGroupMemberSubscriptionTombstoneRecord(groupId, memberId2),
            // Subscription metadata is recomputed because zar is no longer there.
            GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                barTopicName, computeTopicHash(barTopicName, metadataImage)
            )))
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testShareGroupNewMemberIsRejectedWithMaximumMembersIsReached() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        // A share group cannot have pre-defined members and member metadata as members and assignments
        // are not persisted.
        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withConfig(GroupCoordinatorConfig.SHARE_GROUP_MAX_SIZE_CONFIG, 1)
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of()
        ));

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 100, 0));

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 1)
            .addTopic(barTopicId, barTopicName, 1)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(image.emptyDelta(), image);

        // Member 1 joins the group.
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of(fooTopicName, barTopicName)));
        assertEquals(101, result.response().getKey().memberEpoch());

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(
                fooTopicId,
                Set.of(0),
                barTopicId,
                Set.of(0)
            ),
            groupId,
            101,
            true
        );

        // Member 2 joins the group.
        assertThrows(GroupMaxSizeReachedException.class, () -> context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of("foo", "bar"))));
    }

    @Test
    public void testShareGroupDeleteTombstones() {
        String groupId = "share-group-id";
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroup(new ShareGroupBuilder(groupId, 10))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentMetadataTombstoneRecord(groupId),
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataTombstoneRecord(groupId),
            GroupCoordinatorRecordHelpers.newShareGroupEpochTombstoneRecord(groupId)
        );
        List<CoordinatorRecord> records = new ArrayList<>();
        context.groupMetadataManager.createGroupTombstoneRecordsAndCancelTimers("share-group-id", records);
        assertEquals(expectedRecords, records);
    }

    @Test
    public void testShareGroupStates() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("share-range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withShareGroup(new ShareGroupBuilder(groupId, 10))
            .build();

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 10, 0));

        assertEquals(ShareGroup.ShareGroupState.EMPTY, context.shareGroupState(groupId));

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupMemberSubscriptionRecord(groupId, new ShareGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setSubscribedTopicNames(List.of(fooTopicName))
            .build()));
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 11, 0));

        assertEquals(ShareGroup.ShareGroupState.STABLE, context.shareGroupState(groupId));

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
            mkTopicAssignment(fooTopicId, 1, 2, 3))));
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentMetadataRecord(groupId, 11, 12345L));

        assertEquals(ShareGroup.ShareGroupState.STABLE, context.shareGroupState(groupId));

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupCurrentAssignmentRecord(groupId, new ShareGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedPartitions(mkAssignment(mkTopicAssignment(fooTopicId, 1, 2)))
            .build()));

        assertEquals(ShareGroup.ShareGroupState.STABLE, context.shareGroupState(groupId));

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupCurrentAssignmentRecord(groupId, new ShareGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedPartitions(mkAssignment(mkTopicAssignment(fooTopicId, 1, 2, 3)))
            .build()));

        assertEquals(ShareGroup.ShareGroupState.STABLE, context.shareGroupState(groupId));
    }

    @Test
    public void testUnknownStreamsGroupId() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        GroupIdNotFoundException e = assertThrows(GroupIdNotFoundException.class, () ->
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(100) // Epoch must be > 0.
                    .setRebalanceTimeoutMs(1500)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of())));
        assertEquals("Streams group fooup not found.", e.getMessage());
    }

    @Test
    public void testUnknownMemberIdJoinsStreamsGroup() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        Topology topology = new Topology();

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .build();

        assignor.prepareGroupAssignment(Map.of(memberId, TasksTuple.EMPTY));

        // A first member joins to create the group.
        context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        // The second member is rejected because the member id is unknown and
        // the member epoch is not zero.
        final String memberId2 = Uuid.randomUuid().toString();
        UnknownMemberIdException e = assertThrows(UnknownMemberIdException.class, () ->
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId2)
                    .setMemberEpoch(1)
                    .setRebalanceTimeoutMs(1500)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of())));
        assertEquals(String.format("Member %s is not a member of group %s.", memberId2, groupId), e.getMessage());
    }

    @Test
    public void testStreamsGroupMemberEpochValidation() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .buildCoordinatorMetadataImage())
            .build();
        assignor.prepareGroupAssignment(Map.of(memberId, TasksTuple.EMPTY));

        StreamsGroupMember member = streamsGroupMemberBuilderWithDefaults(memberId)
            .setMemberEpoch(100)
            .setPreviousMemberEpoch(99)
            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 100, TaskAssignmentTestUtil.mkTasks(subtopology1, 1, 2, 3)))
            .build();

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, member));

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(groupId, 100, 0, 0, Map.of()));

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord(groupId, topology));

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId,
            TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 1, 2, 3)
            )));

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 100, 12345L));

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, member));

        // Member epoch is greater than the expected epoch.
        FencedMemberEpochException e1 = assertThrows(FencedMemberEpochException.class, () ->
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(200)
                    .setRebalanceTimeoutMs(1500)));
        assertEquals("The streams group member has a greater member epoch (200) than the one known by the group coordinator (100). "
            + "The member must abandon all its partitions and rejoin.", e1.getMessage());

        // Member epoch is smaller than the expected epoch.
        FencedMemberEpochException e2 = assertThrows(FencedMemberEpochException.class, () ->
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(50)
                    .setRebalanceTimeoutMs(1500)));
        assertEquals("The streams group member has a smaller member epoch (50) than the one known by the group coordinator (100). "
            + "The member must abandon all its partitions and rejoin.", e2.getMessage());

        // Member joins with previous epoch but without providing tasks.
        FencedMemberEpochException e3 = assertThrows(FencedMemberEpochException.class, () ->
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(99)
                    .setRebalanceTimeoutMs(1500)));
        assertEquals("The streams group member has a smaller member epoch (99) than the one known by the group coordinator (100). "
            + "The member must abandon all its partitions and rejoin.", e3.getMessage());

        // Member joins with previous epoch and has a subset of the owned tasks.
        // This is accepted as the response with the bumped epoch may have been lost.
        // In this case, we provide back the correct epoch to the member.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(99)
                .setRebalanceTimeoutMs(1500)
                .setActiveTasks(List.of(new StreamsGroupHeartbeatRequestData.TaskIds()
                    .setSubtopologyId(subtopology1)
                    .setPartitions(List.of(1, 2))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));
        assertEquals(100, result.response().data().memberEpoch());
    }

    @Test
    public void testStreamsOwnedTasksValidation() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String subtopologyMissing = "subtopologyMissing";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 3)
                .buildCoordinatorMetadataImage())
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
                    .build())
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
                .withTargetAssignment(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
                .withTargetAssignmentEpoch(10)
            )
            .build();

        InvalidRequestException e1 = assertThrows(InvalidRequestException.class, () -> context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatRequestData.TaskIds()
                        .setSubtopologyId(subtopologyMissing)
                        .setPartitions(List.of(0))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())));
        assertEquals("Subtopology subtopologyMissing does not exist in the topology.", e1.getMessage());

        InvalidRequestException e2 = assertThrows(InvalidRequestException.class, () -> context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatRequestData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(3))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())));
        assertEquals("Task 3 for subtopology subtopology1 is invalid. Number of tasks for this subtopology: 3", e2.getMessage());
    }

    @Test
    public void testStreamsNewMemberIsRejectedWithMaximumMembersIsReached() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();
        Topology topology = new Topology().setSubtopologies(List.of());

        // Create a context with one streams group containing two members.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(new MetadataImageBuilder().buildCoordinatorMetadataImage())
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_MAX_SIZE_CONFIG, 2)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .build())
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId2)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .build())
                .withTargetAssignmentEpoch(10)
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
            )
            .build();

        assertThrows(GroupMaxSizeReachedException.class, () ->
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId3)
                    .setMemberEpoch(0)
                    .setProcessId("process-id")
                    .setRebalanceTimeoutMs(1500)
                    .setTopology(topology)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of())
            ));
    }

    @Test
    public void testMemberJoinsEmptyStreamsGroup() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();

        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String subtopology2 = "subtopology2";
        String barTopicName = "bar";
        Uuid barTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName)),
            new Subtopology().setSubtopologyId(subtopology2).setSourceTopics(List.of(barTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage();
        long groupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, metadataImage),
            barTopicName, computeTopicHash(barTopicName, metadataImage)
        ));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        assignor.prepareGroupAssignment(Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
            TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5),
            TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1, 2)
        )));

        assertThrows(GroupIdNotFoundException.class, () ->
            context.groupMetadataManager.streamsGroup(groupId));

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setProcessId("process-id")
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1, 2, 3, 4, 5)),
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology2)
                        .setPartitions(List.of(0, 1, 2))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        StreamsGroupMember expectedMember = streamsGroupMemberBuilderWithDefaults(memberId)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(1500)
            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 2,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5),
                TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1, 2)))
            
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, expectedMember),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord(groupId, topology),
            StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                groupId,
                2,
                groupMetadataHash,
                0,
                new TreeMap<>(Map.of(
                    "num.standby.replicas", "0"
                ))
            ),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId,
                TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5),
                    TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1, 2)
                )),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testJoinEmptyStreamsGroupAndDescribe() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();

        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        assignor.prepareGroupAssignment(Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
            TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)
        )));

        assertThrows(GroupIdNotFoundException.class, () ->
            context.groupMetadataManager.streamsGroup(groupId));

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setProcessId("process-id")
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        StreamsGroupMember expectedMember = streamsGroupMemberBuilderWithDefaults(memberId)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(1500)
            .setAssignedTasks(TaskAssignmentTestUtil.mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 1,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)))
            .build();

        // Commit the offset, so that the latest state will be described below
        context.commit();

        List<StreamsGroupDescribeResponseData.DescribedGroup> actualDescribedGroups = context.groupMetadataManager.streamsGroupDescribe(List.of(groupId), context.lastCommittedOffset);
        StreamsGroupDescribeResponseData.DescribedGroup expectedDescribedGroup = new StreamsGroupDescribeResponseData.DescribedGroup()
            .setGroupId(groupId)
            .setAssignmentEpoch(2)
            .setTopology(
                new StreamsGroupDescribeResponseData.Topology()
                    .setEpoch(0)
                    .setSubtopologies(List.of(
                        new StreamsGroupDescribeResponseData.Subtopology()
                            .setSubtopologyId(subtopology1)
                            .setSourceTopics(List.of(fooTopicName))
                    ))
            )
            .setMembers(Collections.singletonList(
                expectedMember.asStreamsGroupDescribeMember(TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)))
            ))
            .setGroupState(StreamsGroupState.STABLE.toString())
            .setGroupEpoch(2);
        assertEquals(1, actualDescribedGroups.size());
        assertEquals(expectedDescribedGroup, actualDescribedGroups.get(0));
    }

    @Test
    public void testStreamsGroupMemberJoiningWithMissingSourceTopic() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String subtopology2 = "subtopology2";
        String barTopicName = "bar";
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName)),
            new Subtopology().setSubtopologyId(subtopology2).setSourceTopics(List.of(barTopicName))
        ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        // Member joins the streams group.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setProcessId(DEFAULT_PROCESS_ID)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertEquals(
            Map.of(),
            result.response().creatableTopics()
        );
        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of(new StreamsGroupHeartbeatResponseData.Status()
                    .setStatusCode(Status.MISSING_SOURCE_TOPICS.code())
                    .setStatusDetail("Source topics bar are missing."))),
            result.response().data()
        );

        StreamsGroupMember expectedMember = streamsGroupMemberBuilderWithDefaults(memberId)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(1500)
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, expectedMember),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord(groupId, topology),
            StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                groupId,
                2,
                computeGroupHash(Map.of(fooTopicName, computeTopicHash(fooTopicName, metadataImage))),
                -1,
                new TreeMap<>(Map.of(
                    "num.standby.replicas", "0"
                ))
            ),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId, TasksTuple.EMPTY),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testStreamsGroupMemberJoiningWithMissingInternalTopic() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Topology topology = new Topology().setSubtopologies(List.of(
                new Subtopology()
                    .setSubtopologyId(subtopology1)
                    .setSourceTopics(List.of(fooTopicName))
                    .setStateChangelogTopics(List.of(new TopicInfo().setName(barTopicName)))
            )
        );

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        // Member joins the streams group.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setProcessId(DEFAULT_PROCESS_ID)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertEquals(
            Map.of(barTopicName,
                new CreatableTopic()
                    .setName(barTopicName)
                    .setNumPartitions(6)
                    .setReplicationFactor((short) -1)
            ),
            result.response().creatableTopics()
        );
        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of(new StreamsGroupHeartbeatResponseData.Status()
                    .setStatusCode(Status.MISSING_INTERNAL_TOPICS.code())
                    .setStatusDetail("Internal topics are missing: bar"))),
            result.response().data()
        );

        StreamsGroupMember expectedMember = streamsGroupMemberBuilderWithDefaults(memberId)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(1500)
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, expectedMember),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord(groupId, topology),
            StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                groupId,
                2,
                computeGroupHash(Map.of(fooTopicName, computeTopicHash(fooTopicName, metadataImage))),
                -1,
                new TreeMap<>(Map.of(
                    "num.standby.replicas", "0"
                ))
            ),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId, TasksTuple.EMPTY),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertEquals(StreamsGroupState.NOT_READY, context.streamsGroupState(groupId));
        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testStreamsGroupMemberJoiningWithIncorrectlyPartitionedTopic() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Uuid barTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
                new Subtopology()
                    .setSubtopologyId(subtopology1)
                    .setSourceTopics(List.of(fooTopicName, barTopicName))
                    .setCopartitionGroups(List.of(new CopartitionGroup().setSourceTopics(List.of((short) 0, (short) 1))))
            )
        );

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage();
        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        // Member joins the streams group.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setProcessId(DEFAULT_PROCESS_ID)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertEquals(
            Map.of(),
            result.response().creatableTopics()
        );
        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of(new StreamsGroupHeartbeatResponseData.Status()
                    .setStatusCode(Status.INCORRECTLY_PARTITIONED_TOPICS.code())
                    .setStatusDetail("Following topics do not have the same number of partitions: [{bar=3, foo=6}]"))),
            result.response().data()
        );

        StreamsGroupMember expectedMember = streamsGroupMemberBuilderWithDefaults(memberId)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(1500)
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, expectedMember),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord(groupId, topology),
            StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                groupId,
                2,
                computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                )),
                -1,
                new TreeMap<>(Map.of(
                    "num.standby.replicas", "0"
                ))
            ),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId, TasksTuple.EMPTY),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertEquals(StreamsGroupState.NOT_READY, context.streamsGroupState(groupId));
        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testStreamsGroupMemberJoiningWithStaleTopology() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Uuid barTopicId = Uuid.randomUuid();
        Topology topology0 = new Topology().setEpoch(0).setSubtopologies(List.of(
                new Subtopology()
                    .setSubtopologyId(subtopology1)
                    .setSourceTopics(List.of(fooTopicName))
            )
        );
        Topology topology1 = new Topology().setEpoch(1).setSubtopologies(List.of(
                new Subtopology()
                    .setSubtopologyId(subtopology1)
                    .setSourceTopics(List.of(fooTopicName, barTopicName))
            )
        );

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage();
        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .withStreamsGroup(
                new StreamsGroupBuilder(groupId, 10)
                    .withTopology(StreamsTopology.fromHeartbeatRequest(topology1))
                    .withValidatedTopologyEpoch(1)
            )
            .build();

        assignor.prepareGroupAssignment(new org.apache.kafka.coordinator.group.streams.assignor.GroupAssignment(Map.of(
            memberId, org.apache.kafka.coordinator.group.streams.assignor.MemberAssignment.empty()
        )));

        // Member joins the streams group.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology0)
                .setProcessId(DEFAULT_PROCESS_ID)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertEquals(
            Map.of(),
            result.response().creatableTopics()
        );
        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of(new StreamsGroupHeartbeatResponseData.Status()
                    .setStatusCode(Status.STALE_TOPOLOGY.code())
                    .setStatusDetail("The member's topology epoch 0 is behind the group's topology epoch 1."))),
            result.response().data()
        );

        StreamsGroupMember expectedMember = streamsGroupMemberBuilderWithDefaults(memberId)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(1500)
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, expectedMember),
            StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                groupId,
                11,
                computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                )),
                1,
                new TreeMap<>(Map.of(
                    "num.standby.replicas", "0"
                ))
            ),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId, TasksTuple.EMPTY),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testStreamsGroupMemberRequestingShutdownApplication() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();
        long groupMetadataHash = computeGroupHash(Map.of(fooTopicName, computeTopicHash(fooTopicName, metadataImage)));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId1)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
                    .build())
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId2)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 3, 4, 5)))
                    .build())
                .withTargetAssignment(memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
                .withTargetAssignment(memberId2, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 3, 4, 5)))
                .withTargetAssignmentEpoch(10)
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
                .withMetadataHash(groupMetadataHash)
                .withValidatedTopologyEpoch(0)
                .withLastAssignmentConfigs(getDefaultAssignmentConfigs())
            )
            .build();

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result1 = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(10)
                .setShutdownApplication(true)
        );

        String statusDetail = String.format("Streams group member %s encountered a fatal error and requested a shutdown for the entire application.", memberId1);

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setStatus(List.of(
                    new StreamsGroupHeartbeatResponseData.Status()
                        .setStatusCode(Status.SHUTDOWN_APPLICATION.code())
                        .setStatusDetail(statusDetail)
                )),
            result1.response().data()
        );
        assertRecordsEquals(List.of(), result1.records());

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result2 = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
        );

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setStatus(List.of(
                    new StreamsGroupHeartbeatResponseData.Status()
                        .setStatusCode(Status.SHUTDOWN_APPLICATION.code())
                        .setStatusDetail(statusDetail)
                )),
            result2.response().data()
        );

        assertRecordsEquals(List.of(), result2.records());
    }

    @Test
    public void testStreamsGroupMemberRequestingShutdownApplicationUponLeaving() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 2)
            .buildCoordinatorMetadataImage();

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        long metadataHash = computeGroupHash(Map.of(fooTopicName, computeTopicHash(fooTopicName, metadataImage)));

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId1)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .build())
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId2)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .build())
                .withTargetAssignmentEpoch(10)
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
                .withValidatedTopologyEpoch(0)
                .withMetadataHash(metadataHash)
                .withLastAssignmentConfigs(getDefaultAssignmentConfigs())
            )
            .build();

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result1 = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH)
                .setShutdownApplication(true)
        );

        String statusDetail = String.format("Streams group member %s encountered a fatal error and requested a shutdown for the entire application.", memberId1);

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH)
                .setStatus(List.of()),
            result1.response().data()
        );
        assertRecordsEquals(
            List.of(
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
                StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
                StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord(groupId, memberId1),
                StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(groupId, 11, metadataHash, 0, getDefaultAssignmentConfigs())
            ),
            result1.records()
        );

        assignor.prepareGroupAssignment(Map.of());

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result2 = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
        );

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setStatus(List.of(
                    new StreamsGroupHeartbeatResponseData.Status()
                        .setStatusCode(Status.SHUTDOWN_APPLICATION.code())
                        .setStatusDetail(statusDetail)
                )),
            result2.response().data()
        );
    }

    @Test
    public void testStreamsUpdatingMemberMetadataTriggersNewTargetAssignment() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String subtopology2 = "subtopology2";
        String barTopicName = "bar";
        Uuid barTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName)),
            new Subtopology().setSubtopologyId(subtopology2).setSourceTopics(List.of(barTopicName))
        ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage();

        long groupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, metadataImage),
            barTopicName, computeTopicHash(barTopicName, metadataImage)
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)))
                    .build())
                .withTargetAssignment(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)))
                .withTargetAssignmentEpoch(10)
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
                .withMetadataHash(groupMetadataHash)
            )
            .build();

        assignor.prepareGroupAssignment(
            Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5),
                TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1, 2)
            ))
        );

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10)
                .setProcessId("process-id2")
        );

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1, 2, 3, 4, 5)),
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology2)
                        .setPartitions(List.of(0, 1, 2))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        StreamsGroupMember expectedMember = streamsGroupMemberBuilderWithDefaults(memberId)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedTasks(mkTasksTupleWithEpochs(TaskRole.ACTIVE,
                mkTasksWithEpochs(subtopology1,
                    Map.of(
                        0, 10,
                        1, 10,
                        2, 10,
                        3, 10,
                        4, 10,
                        5, 10
                    )),
                mkTasksWithEpochs(subtopology2,
                    Map.of(
                        0, 11,
                        1, 11,
                        2, 11
                    ))))
            .setProcessId("process-id2")
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, expectedMember),
            StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                groupId,
                11,
                groupMetadataHash,
                0,
                new TreeMap<>(Map.of(
                    "num.standby.replicas", "0"
                ))
            ),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId,
                TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5),
                    TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1, 2)
                )),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testStreamsUpdatingPartitionMetadataTriggersNewTargetAssignment() {
        int changedPartitionCount = 6; // New partition count for the topic.
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String subtopology2 = "subtopology2";
        String barTopicName = "bar";
        Uuid barTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName)),
            new Subtopology().setSubtopologyId(subtopology2).setSourceTopics(List.of(barTopicName))
        ));

        CoordinatorMetadataImage newMetadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, changedPartitionCount)
            .buildCoordinatorMetadataImage();

        CoordinatorMetadataImage oldMetadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage();
        long oldGroupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, oldMetadataImage),
            barTopicName, computeTopicHash(barTopicName, oldMetadataImage)
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(newMetadataImage)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)))
                    .build())
                .withTargetAssignment(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)))
                .withTargetAssignmentEpoch(10)
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
                .withMetadataHash(oldGroupMetadataHash)
            )
            .build();

        assignor.prepareGroupAssignment(
            Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5),
                TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1, 2)
            ))
        );

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10)
        );

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1, 2, 3, 4, 5)),
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology2)
                        .setPartitions(List.of(0, 1, 2))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        StreamsGroupMember expectedMember = streamsGroupMemberBuilderWithDefaults(memberId)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedTasks(mkTasksTupleWithEpochs(TaskRole.ACTIVE,
                mkTasksWithEpochs(subtopology1,
                    Map.of(
                        0, 10,
                        1, 10,
                        2, 10,
                        3, 10,
                        4, 10,
                        5, 10
                    )),
                mkTasksWithEpochs(subtopology2,
                    Map.of(
                        0, 11,
                        1, 11,
                        2, 11
                    ))))
            .setProcessId("process-id2")
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                groupId,
                11,
                computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, newMetadataImage),
                    barTopicName, computeTopicHash(barTopicName, newMetadataImage)
                )),
                0,
                new TreeMap<>(Map.of(
                    "num.standby.replicas", "0"
                ))
            ),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId,
                TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5),
                    TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1, 2)
                )),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testStreamsNewJoiningMemberTriggersNewTargetAssignment() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String subtopology2 = "subtopology2";
        String barTopicName = "bar";
        Uuid barTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName)),
            new Subtopology().setSubtopologyId(subtopology2).setSourceTopics(List.of(barTopicName))
        ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage();

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addTopic(barTopicId, barTopicName, 3)
                .buildCoordinatorMetadataImage())
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId1)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2),
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1)))
                    .build())
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId2)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 3, 4, 5),
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 2)))
                    .build())
                .withTargetAssignment(memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2),
                    TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1)))
                .withTargetAssignment(memberId2, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 3, 4, 5),
                    TaskAssignmentTestUtil.mkTasks(subtopology2, 2)))
                .withTargetAssignmentEpoch(10)
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                )))
            )
            .build();

        assignor.prepareGroupAssignment(Map.of(
            memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1),
                TaskAssignmentTestUtil.mkTasks(subtopology2, 0)
            ),
            memberId2, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 2, 3),
                TaskAssignmentTestUtil.mkTasks(subtopology2, 1)
            ),
            memberId3, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 4, 5),
                TaskAssignmentTestUtil.mkTasks(subtopology2, 2)
            )
        ));

        // Member 3 joins the streams group.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId3)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setProcessId(DEFAULT_PROCESS_ID)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),

            result.response().data()
        );

    }

    @Test
    public void testStreamsLeavingMemberRemovesMemberAndBumpsGroupEpoch() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String subtopology2 = "subtopology2";
        String barTopicName = "bar";
        Uuid barTopicId = Uuid.randomUuid();

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addTopic(barTopicId, barTopicName, 3)
                .buildCoordinatorMetadataImage())
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2),
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1)))
                    .build())
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId2)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 3, 4, 5),
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 2)))
                    .build())
                .withTargetAssignment(memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2),
                    TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1)))
                .withTargetAssignment(memberId2, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 3, 4, 5),
                    TaskAssignmentTestUtil.mkTasks(subtopology2, 2)))
                .withTargetAssignmentEpoch(10))
            .build();

        // Member 2 leaves the streams group.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH)
                .setRebalanceTimeoutMs(1500)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH)
                .setStatus(List.of()),
            result.response().data()
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord(groupId, memberId2),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord(groupId, memberId2),
            StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord(groupId, memberId2),
            StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(groupId, 11, 0, -1, Map.of())
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testStreamsGroupHeartbeatPartialResponseWhenNothingChanges() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 2)
                .buildCoordinatorMetadataImage())
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        // Prepare new assignment for the group.
        assignor.prepareGroupAssignment(
            Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE, TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1))));

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result;

        // A full response should be sent back on joining.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        // Otherwise, a partial response should be sent back.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().data().memberEpoch()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setStatus(List.of()),
            result.response().data()
        );
    }

    @Test
    public void testStreamsGroupHeartbeatAlwaysSetsStatus() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 2)
                .buildCoordinatorMetadataImage())
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        // Prepare new assignment for the group.
        assignor.prepareGroupAssignment(
            Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE, TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1))));

        // Heartbeat with no errors should still have status field set to empty list.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        // Verify that status is always set, even when empty.
        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        // Verify status field is present in subsequent heartbeats as well.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().data().memberEpoch()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setStatus(List.of()),
            result.response().data()
        );
    }

    @Test
    public void testStreamsInitialRebalanceDelayEmptyDuringDelayAssignsAfterTimer() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 2)
                .buildCoordinatorMetadataImage())
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 1000)
            .build();

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result;

        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(1)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setPartitionsByUserEndpoint(null)
                .setEndpointInformationEpoch(0)
                .setStatus(List.of(
                    new StreamsGroupHeartbeatResponseData.Status()
                        .setStatusCode(Status.ASSIGNMENT_DELAYED.code())
                        .setStatusDetail("Assignment delayed due to the configured initial rebalance delay.")
                )),
            result.response().data()
        );

        assignor.prepareGroupAssignment(
                Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE, TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1))));

        context.sleep(10000);

        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(1)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );
    }

    @Test
    public void testStreamsRebalanceDelayWhenJoiningEmptyGroupWithNonZeroEpoch() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 2)
                .buildCoordinatorMetadataImage())
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 1000)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10))
            .build();

        StreamsGroup group = context.groupMetadataManager.streamsGroup(groupId);
        assertTrue(group.isEmpty());
        assertEquals(10, group.groupEpoch());

        assignor.prepareGroupAssignment(
            Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE, TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1))));

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result;

        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(10000)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        int memberEpoch = result.response().data().memberEpoch();
        assertTrue(result.response().data().activeTasks().isEmpty());

        context.sleep(2000);

        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(memberEpoch)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertFalse(result.response().data().activeTasks().isEmpty());
    }

    @Test
    public void testStreamsReconciliationProcess() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String subtopology2 = "subtopology2";
        String barTopicName = "bar";
        Uuid barTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName)),
            new Subtopology().setSubtopologyId(subtopology2).setSourceTopics(List.of(barTopicName))
        ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage();
        long groupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, metadataImage),
            barTopicName, computeTopicHash(barTopicName, metadataImage)
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2),
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1)))
                    .build())
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId2)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 3, 4, 5),
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 2)))
                    .build())
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
                .withTargetAssignment(memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2),
                    TaskAssignmentTestUtil.mkTasks(subtopology2, 0, 1)))
                .withTargetAssignment(memberId2, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 3, 4, 5),
                    TaskAssignmentTestUtil.mkTasks(subtopology2, 2)))
                .withTargetAssignmentEpoch(10)
                .withMetadataHash(groupMetadataHash)
                .withValidatedTopologyEpoch(0)
            )
            .build();

        // Prepare new assignment for the group.
        assignor.prepareGroupAssignment(Map.of(
            memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1),
                TaskAssignmentTestUtil.mkTasks(subtopology2, 0)
            ),
            memberId2, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 2, 3),
                TaskAssignmentTestUtil.mkTasks(subtopology2, 2)
            ),
            memberId3, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 4, 5),
                TaskAssignmentTestUtil.mkTasks(subtopology2, 1)
            )
        ));

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result;

        // Members in the group are in Stable state.
        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.STABLE, context.streamsGroupMemberState(groupId, memberId1));
        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.STABLE, context.streamsGroupMemberState(groupId, memberId2));
        assertEquals(StreamsGroup.StreamsGroupState.STABLE, context.streamsGroupState(groupId));

        // Member 3 joins the group. This triggers the computation of a new target assignment
        // for the group. Member 3 does not get any assigned tasks yet because they are
        // all owned by other members. However, it transitions to epoch 11 and the
        // Unreleased Tasks state.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId3)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        // We only check the last record as the subscription/target assignment updates are
        // already covered by other tests.
        assertRecordEquals(
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId3)
                .setState(org.apache.kafka.coordinator.group.streams.MemberState.UNRELEASED_TASKS)
                .setMemberEpoch(11)
                .setPreviousMemberEpoch(0)
                .build()),
            result.records().get(result.records().size() - 1)
        );

        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.UNRELEASED_TASKS,
            context.streamsGroupMemberState(groupId, memberId3));
        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        // Member 1 heartbeats. It remains at epoch 10 but transitions to Unrevoked Tasks
        // state until it acknowledges the revocation of its tasks. The response contains the new
        // assignment without the tasks that must be revoked.
        result = context.streamsGroupHeartbeat(new StreamsGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId1)
            .setMemberEpoch(10));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1)),
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology2)
                        .setPartitions(List.of(0))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        assertRecordsEquals(List.of(
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId1)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.UNREVOKED_TASKS)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1),
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 0)))
                    .setTasksPendingRevocation(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 2),
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 1)))
                    .build())),
            result.records()
        );

        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.UNREVOKED_TASKS,
            context.streamsGroupMemberState(groupId, memberId1));
        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        // Member 2 heartbeats. It remains at epoch 10 but transitions to Unrevoked Tasks
        // state until it acknowledges the revocation of its tasks. The response contains the new
        // assignment without the tasks that must be revoked.
        result = context.streamsGroupHeartbeat(new StreamsGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId2)
            .setMemberEpoch(10));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(3)),
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology2)
                        .setPartitions(List.of(2))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        assertRecordsEquals(List.of(
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId2)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.UNREVOKED_TASKS)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 3),
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 2)))
                    .setTasksPendingRevocation(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 4, 5)))
                    .build())),
            result.records()
        );

        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.UNREVOKED_TASKS,
            context.streamsGroupMemberState(groupId, memberId2));
        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        // Member 3 heartbeats. The response does not contain any assignment
        // because the member is still waiting on other members to revoke tasks.
        result = context.streamsGroupHeartbeat(new StreamsGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId3)
            .setMemberEpoch(11));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setStatus(List.of()),
            result.response().data()
        );

        assertRecordsEquals(List.of(
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId3)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.UNRELEASED_TASKS)
                    .setMemberEpoch(11)
                    .setPreviousMemberEpoch(11)
                    .build())),
            result.records()
        );

        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.UNRELEASED_TASKS,
            context.streamsGroupMemberState(groupId, memberId3));
        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        // Member 1 acknowledges the revocation of the tasks. It does so by providing the
        // tasks that it still owns in the request. This allows him to transition to epoch 11
        // and to the Stable state.
        result = context.streamsGroupHeartbeat(new StreamsGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId1)
            .setMemberEpoch(10)
            .setActiveTasks(List.of(
                new StreamsGroupHeartbeatRequestData.TaskIds()
                    .setSubtopologyId(subtopology1)
                    .setPartitions(List.of(0, 1)),
                new StreamsGroupHeartbeatRequestData.TaskIds()
                    .setSubtopologyId(subtopology2)
                    .setPartitions(List.of(0))
            ))
            .setStandbyTasks(List.of())
            .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setStatus(List.of()),
            result.response().data()
        );

        assertRecordsEquals(List.of(
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId1)
                    .setMemberEpoch(11)
                    .setPreviousMemberEpoch(10)
                    // Assignment epoch not bumped
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1),
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 0)))
                    .build())),
            result.records()
        );

        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.STABLE, context.streamsGroupMemberState(groupId, memberId1));
        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        // Member 2 heartbeats but without acknowledging the revocation yet. This is basically a no-op.
        result = context.streamsGroupHeartbeat(new StreamsGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId2)
            .setMemberEpoch(10));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setStatus(List.of()),
            result.response().data()
        );

        assertEquals(List.of(), result.records());
        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.UNREVOKED_TASKS,
            context.streamsGroupMemberState(groupId, memberId2));
        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        // Member 3 heartbeats. It receives the tasks revoked by member 1 but remains
        // in Unreleased tasks state because it still waits on other tasks.
        result = context.streamsGroupHeartbeat(new StreamsGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId3)
            .setMemberEpoch(11));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology2)
                        .setPartitions(List.of(1))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        assertRecordsEquals(List.of(
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId3)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.UNRELEASED_TASKS)
                    .setMemberEpoch(11)
                    .setPreviousMemberEpoch(11)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 11,
                        TaskAssignmentTestUtil.mkTasks(subtopology2, 1)))
                    .build())),
            result.records()
        );

        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.UNRELEASED_TASKS,
            context.streamsGroupMemberState(groupId, memberId3));
        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        // Member 3 heartbeats. Member 2 has not acknowledged the revocation of its tasks so
        // member keeps its current assignment.
        result = context.streamsGroupHeartbeat(new StreamsGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId3)
            .setMemberEpoch(11));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setStatus(List.of()),
            result.response().data()
        );

        assertEquals(List.of(), result.records());
        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.UNRELEASED_TASKS,
            context.streamsGroupMemberState(groupId, memberId3));
        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        // Member 2 acknowledges the revocation of the tasks. It does so by providing the
        // tasks that it still owns in the request. This allows him to transition to epoch 11
        // and to the Stable state.
        result = context.streamsGroupHeartbeat(new StreamsGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId2)
            .setMemberEpoch(10)
            .setActiveTasks(List.of(
                new StreamsGroupHeartbeatRequestData.TaskIds()
                    .setSubtopologyId(subtopology1)
                    .setPartitions(List.of(3)),
                new StreamsGroupHeartbeatRequestData.TaskIds()
                    .setSubtopologyId(subtopology2)
                    .setPartitions(List.of(2))
            ))
            .setStandbyTasks(List.of())
            .setWarmupTasks(List.of())
        );

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(2, 3)),
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology2)
                        .setPartitions(List.of(2))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        assertRecordsEquals(List.of(
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId2)
                    .setMemberEpoch(11)
                    .setPreviousMemberEpoch(10)
                    // Assignment epoch of previous tasks is preserved, new tasks gets new assignment epoch
                    .setAssignedTasks(mkTasksTupleWithEpochs(TaskRole.ACTIVE,
                        mkTasksWithEpochs(subtopology1, Map.of(2, 11, 3, 10)),
                        mkTasksWithEpochs(subtopology2, Map.of(2, 10))
                    ))
                    .build())),
            result.records()
        );

        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.STABLE, context.streamsGroupMemberState(groupId, memberId2));
        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        // Member 3 heartbeats to acknowledge its current assignment. It receives all its tasks and
        // transitions to Stable state.
        result = context.streamsGroupHeartbeat(new StreamsGroupHeartbeatRequestData()
            .setGroupId(groupId)
            .setMemberId(memberId3)
            .setMemberEpoch(11)
            .setActiveTasks(List.of(
                new StreamsGroupHeartbeatRequestData.TaskIds()
                    .setSubtopologyId(subtopology2)
                    .setPartitions(List.of(1))))
            .setStandbyTasks(List.of())
            .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId3)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(4, 5)),
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology2)
                        .setPartitions(List.of(1))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        assertRecordsEquals(List.of(
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId3)
                    .setMemberEpoch(11)
                    .setPreviousMemberEpoch(11)
                    // All tasks were assigned in epoch 11
                    .setAssignedTasks(mkTasksTupleWithEpochs(TaskRole.ACTIVE,
                        mkTasksWithEpochs(subtopology1, Map.of(4, 11, 5, 11)),
                        mkTasksWithEpochs(subtopology2, Map.of(1, 11))))
                    .build())),
            result.records()
        );

        assertEquals(org.apache.kafka.coordinator.group.streams.MemberState.STABLE, context.streamsGroupMemberState(groupId, memberId3));
        assertEquals(StreamsGroup.StreamsGroupState.STABLE, context.streamsGroupState(groupId));
    }

    @Test
    public void testStreamsStreamsGroupStates() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String subtopology2 = "subtopology2";
        String barTopicName = "bar";
        Uuid barTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName)),
            new Subtopology().setSubtopologyId(subtopology2).setSourceTopics(List.of(barTopicName))
        ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage();

        long groupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, metadataImage),
            barTopicName, computeTopicHash(barTopicName, metadataImage)
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10))
            .build();

        assertEquals(StreamsGroup.StreamsGroupState.EMPTY, context.streamsGroupState(groupId));

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord(groupId, topology));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId1)
            .build()));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(groupId, 11, groupMetadataHash, -1, Map.of()));

        assertEquals(StreamsGroupState.NOT_READY, context.streamsGroupState(groupId));

        context.groupMetadataManager.getStreamsGroupOrThrow(groupId)
            .setValidatedTopologyEpoch(0);

        assertEquals(StreamsGroup.StreamsGroupState.ASSIGNING, context.streamsGroupState(groupId));

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId1,
            TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 1, 2, 3))));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 11, 12345L));

        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        context.replay(
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId1)
                .setState(org.apache.kafka.coordinator.group.streams.MemberState.UNREVOKED_TASKS)
                .setMemberEpoch(11)
                .setPreviousMemberEpoch(10)
                .setAssignedTasks(
                    mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 11,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 1, 2, 3)))
                .build()));

        assertEquals(StreamsGroup.StreamsGroupState.RECONCILING, context.streamsGroupState(groupId));

        context.replay(
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberId1)
                .setMemberEpoch(11)
                .setPreviousMemberEpoch(10)
                .setAssignedTasks(
                    mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 11,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 1, 2, 3)))
                .build()));

        assertEquals(StreamsGroup.StreamsGroupState.STABLE, context.streamsGroupState(groupId));
    }

    @Test
    public void testStreamsTaskAssignorExceptionOnRegularHeartbeat() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        String subtopology2 = "subtopology2";
        String barTopicName = "bar";
        Uuid barTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName)),
            new Subtopology().setSubtopologyId(subtopology2).setSourceTopics(List.of(barTopicName))
        ));

        TaskAssignor assignor = mock(TaskAssignor.class);
        when(assignor.name()).thenReturn("sticky");
        when(assignor.assign(any(), any())).thenThrow(new TaskAssignorException("Assignment failed."));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addTopic(barTopicId, barTopicName, 3)
                .buildCoordinatorMetadataImage())
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        // Member 1 joins the streams group. The request fails because the
        // target assignment computation failed.
        UnknownServerException e = assertThrows(UnknownServerException.class, () ->
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId1)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(1500)
                    .setTopology(topology)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of())));
        assertEquals("Failed to compute a new target assignment for epoch 2: Assignment failed.", e.getMessage());
    }

    @Test
    public void testStreamsPartitionMetadataRefreshedAfterGroupIsLoaded() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
                    .build())
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
                .withTargetAssignment(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
                .withTargetAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    // foo only has 3 tasks stored in the metadata but foo has
                    // 6 partitions the metadata image.
                    fooTopicName, computeTopicHash(
                        fooTopicName,
                        new MetadataImageBuilder()
                            .addTopic(fooTopicId, fooTopicName, 3)
                            .buildCoordinatorMetadataImage())
                ))))
            .build();

        // The metadata refresh flag should be true.
        StreamsGroup streamsGroup = context.groupMetadataManager
            .streamsGroup(groupId);
        assertTrue(streamsGroup.hasMetadataExpired(context.time.milliseconds()));

        // Prepare the assignment result.
        assignor.prepareGroupAssignment(Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
            TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)
        )));

        // Heartbeat.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10));

        // The member gets tasks 3, 4 and 5 assigned.
        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        StreamsGroupMember expectedMember = streamsGroupMemberBuilderWithDefaults(memberId)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedTasks(mkTasksTupleWithEpochs(TaskRole.ACTIVE,
                mkTasksWithEpochs(subtopology1,
                    Map.of(
                        0, 10, // 0, 1, 2 were already assigned in epoch 10
                        1, 10,
                        2, 10,
                        3, 11, // 3, 4, 5 were added in epoch 11
                        4, 11,
                        5, 11
                    ))
            ))
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                groupId,
                11,
                computeGroupHash(Map.of(fooTopicName, computeTopicHash(fooTopicName, metadataImage))),
                0,
                new TreeMap<>(Map.of(
                    "num.standby.replicas", "0"
                ))
            ),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId,
                TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)
                )),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());

        // Check next refresh time.
        assertFalse(streamsGroup.hasMetadataExpired(context.time.milliseconds()));
        assertEquals(context.time.milliseconds() + Integer.MAX_VALUE, streamsGroup.metadataRefreshDeadline().deadlineMs);
        assertEquals(11, streamsGroup.metadataRefreshDeadline().epoch);
    }

    @Test
    public void testStreamsPartitionMetadataRefreshedAgainAfterWriteFailure() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 11,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
                    .build())
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
                .withTargetAssignment(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
                .withTargetAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    // foo only has 3 partitions stored in the metadata but foo has
                    // 6 partitions the metadata image.
                    fooTopicName, computeTopicHash(
                        fooTopicName,
                        new MetadataImageBuilder()
                            .addTopic(fooTopicId, fooTopicName, 3)
                            .buildCoordinatorMetadataImage())
                ))))
            .build();

        // The metadata refresh flag should be true.
        StreamsGroup streamsGroup = context.groupMetadataManager
            .streamsGroup(groupId);
        assertTrue(streamsGroup.hasMetadataExpired(context.time.milliseconds()));

        // Prepare the assignment result.
        assignor.prepareGroupAssignment(
            Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)
            ))
        );

        // Heartbeat.
        context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10));

        // The metadata refresh flag is set to a future time.
        assertFalse(streamsGroup.hasMetadataExpired(context.time.milliseconds()));
        assertEquals(context.time.milliseconds() + Integer.MAX_VALUE, streamsGroup.metadataRefreshDeadline().deadlineMs);
        assertEquals(11, streamsGroup.metadataRefreshDeadline().epoch);

        // Rollback the uncommitted changes. This does not rollback the metadata flag
        // because it is not using a timeline data structure.
        context.rollback();

        // However, the next heartbeat should detect the divergence based on the epoch and trigger
        // a metadata refresh.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10));

        // The member gets tasks 3, 4 and 5 assigned.
        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        StreamsGroupMember expectedMember = streamsGroupMemberBuilderWithDefaults(memberId)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 11,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)))
            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                groupId,
                11,
                computeGroupHash(Map.of(fooTopicName, computeTopicHash(fooTopicName, metadataImage))),
                0,
                new TreeMap<>(Map.of(
                    "num.standby.replicas", "0"
                ))
            ),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId,
                TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)
                )),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember)
        );

        assertRecordsEquals(expectedRecords, result.records());

        // Check next refresh time.
        assertFalse(streamsGroup.hasMetadataExpired(context.time.milliseconds()));
        assertEquals(context.time.milliseconds() + Integer.MAX_VALUE, streamsGroup.metadataRefreshDeadline().deadlineMs);
        assertEquals(11, streamsGroup.metadataRefreshDeadline().epoch);
    }

    @Test
    public void testStreamsSessionTimeoutLifecycle() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .buildCoordinatorMetadataImage())
                .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        assignor.prepareGroupAssignment(Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
            TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)
        )));

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result =
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(90000)
                    .setTopology(topology)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of()));
        assertEquals(2, result.response().data().memberEpoch());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().data().heartbeatIntervalMs())
        );

        // Session timer is rescheduled on second heartbeat.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().data().memberEpoch()));
        assertEquals(2, result.response().data().memberEpoch());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().data().heartbeatIntervalMs())
        );

        // Session timer is cancelled on leave.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH));
        assertEquals(LEAVE_GROUP_MEMBER_EPOCH, result.response().data().memberEpoch());

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
        context.assertNoRebalanceTimeout(groupId, memberId);
    }

    @Test
    public void testStreamsSessionTimeoutExpiration() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));
        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();
        long groupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, metadataImage)
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        assignor.prepareGroupAssignment(Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
            TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)
        )));

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result =
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(90000)
                    .setTopology(topology)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of()));
        assertEquals(2, result.response().data().memberEpoch());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Advance time past the session timeout.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(45000 + 1);

        // Verify the expired timeout.
        assertEquals(
            List.of(new ExpiredTimeout<>(
                groupSessionTimeoutKey(groupId, memberId),
                new CoordinatorResult<>(
                    List.of(
                        StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord(groupId, memberId),
                        StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord(groupId, memberId),
                        StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord(groupId, memberId),
                        StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                            groupId,
                            3,
                            groupMetadataHash,
                            0,
                            new TreeMap<>(Map.of(
                                "num.standby.replicas", "0"
                            ))
                        )
                    )
                )
            )),
            timeouts
        );

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
        context.assertNoRebalanceTimeout(groupId, memberId);
    }

    @Test
    public void testStreamsRebalanceTimeoutLifecycle() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 3)
                .buildCoordinatorMetadataImage())
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        assignor.prepareGroupAssignment(Map.of(memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
            TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)
        )));

        // Member 1 joins the group.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result =
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId1)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(12000)
                    .setTopology(topology)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1, 2))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        assertEquals(
            List.of(),
            context.sleep(result.response().data().heartbeatIntervalMs())
        );

        // Prepare next assignment.
        assignor.prepareGroupAssignment(Map.of(
            memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1)
            ),
            memberId2, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 2)
            )
        ));

        // Member 2 joins the group.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(90000)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(3)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        assertEquals(
            List.of(),
            context.sleep(result.response().data().heartbeatIntervalMs())
        );

        // Member 1 heartbeats and transitions to unrevoked tasks. The rebalance timeout
        // is scheduled.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setRebalanceTimeoutMs(12000));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        // Verify that there is a revocation timeout. Keep a reference
        // to the timeout for later.
        ScheduledTimeout<CoordinatorRecord> scheduledTimeout =
            context.assertRebalanceTimeout(groupId, memberId1, 12000);

        assertEquals(
            List.of(),
            context.sleep(result.response().data().heartbeatIntervalMs())
        );

        // Member 1 acks the revocation. The revocation timeout is cancelled.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setActiveTasks(List.of(new StreamsGroupHeartbeatRequestData.TaskIds()
                    .setSubtopologyId(subtopology1)
                    .setPartitions(List.of(0, 1))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(3)
                .setHeartbeatIntervalMs(5000)
                .setEndpointInformationEpoch(0)
                .setStatus(List.of()),
            result.response().data()
        );

        // Verify that there is not revocation timeout.
        context.assertNoRebalanceTimeout(groupId, memberId1);

        // Execute the scheduled revocation timeout captured earlier to simulate a
        // stale timeout. This should be a no-op.
        assertEquals(List.of(), scheduledTimeout.operation().generateRecords().records());
    }

    @Test
    public void testStreamsRebalanceTimeoutExpiration() {
        final int rebalanceTimeoutMs = 10000;
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));
        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();
        long groupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, metadataImage)
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        assignor.prepareGroupAssignment(
            Map.of(memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE, TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2))));

        // Member 1 joins the group.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result =
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId1)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(rebalanceTimeoutMs) // Use timeout smaller than session timeout.
                    .setTopology(topology)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1, 2))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        assertEquals(
            List.of(),
            context.sleep(result.response().data().heartbeatIntervalMs())
        );

        // Prepare next assignment.
        assignor.prepareGroupAssignment(Map.of(
            memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1)
            ),
            memberId2, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 2)
            )
        ));

        // Member 2 joins the group.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(rebalanceTimeoutMs)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(3)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        assertEquals(
            List.of(),
            context.sleep(result.response().data().heartbeatIntervalMs())
        );

        // Member 1 heartbeats and transitions to revoking. The revocation timeout
        // is scheduled.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(2));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        // Advance time past the revocation timeout.
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(rebalanceTimeoutMs + 1);

        // Verify the expired timeout.
        assertEquals(
            List.of(new ExpiredTimeout<>(
                groupRebalanceTimeoutKey(groupId, memberId1),
                new CoordinatorResult<>(
                    List.of(
                        StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
                        StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
                        StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord(groupId, memberId1),
                        StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                            groupId,
                            4,
                            groupMetadataHash,
                            0,
                            new TreeMap<>(Map.of(
                                "num.standby.replicas", "0"
                            ))
                        )
                    )
                )
            )),
            timeouts
        );

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId1);
        context.assertNoRebalanceTimeout(groupId, memberId1);
    }

    @Test
    public void testStreamsOnMetadataUpdate() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();

        // Topology of group 1 uses a and b.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord("group1",
            new Topology().setSubtopologies(List.of(
                new Subtopology().setSubtopologyId("subtopology1")
                    .setSourceTopics(List.of("a"))
                    .setRepartitionSourceTopics(List.of(new TopicInfo().setName("b"))
            ))
        )));

        // Topology of group 2 uses b and c.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord("group2",
            new Topology().setSubtopologies(List.of(
                new Subtopology().setSubtopologyId("subtopology2")
                    .setSourceTopics(List.of("b"))
                    .setStateChangelogTopics(List.of(new TopicInfo().setName("c")))
            ))
        ));

        // Topology of group 3 uses d.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord("group3",
            new Topology().setSubtopologies(List.of(
                new Subtopology().setSubtopologyId("subtopology3")
                    .setSourceTopics(List.of("d"))
            ))
        ));

        // Topology of group 4 subscribes to e.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord("group4",
            new Topology().setSubtopologies(List.of(
                new Subtopology().setSubtopologyId("subtopology4")
                    .setSourceTopics(List.of("e"))
            ))
        ));

        // Topology of group 5 subscribes to f.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord("group5",
            new Topology().setSubtopologies(List.of(
                new Subtopology().setSubtopologyId("subtopology5")
                    .setSourceTopics(List.of("f"))
            ))
        ));

        // Ensures that all refresh flags are set to the future.
        List.of("group1", "group2", "group3", "group4", "group5").forEach(groupId -> {
            StreamsGroup group = context.groupMetadataManager.streamsGroup(groupId);
            group.setMetadataRefreshDeadline(context.time.milliseconds() + 5000L, 0);
            assertFalse(group.hasMetadataExpired(context.time.milliseconds()));
        });

        // Update the metadata image.
        Uuid topicA = Uuid.randomUuid();
        Uuid topicB = Uuid.randomUuid();
        Uuid topicC = Uuid.randomUuid();
        Uuid topicD = Uuid.randomUuid();
        Uuid topicE = Uuid.randomUuid();

        // Create a first base image with topic a, b, c and d.
        MetadataDelta delta = new MetadataDelta.Builder()
            .setImage(MetadataImage.EMPTY)
            .build();
        delta.replay(new TopicRecord().setTopicId(topicA).setName("a"));
        delta.replay(new PartitionRecord().setTopicId(topicA).setPartitionId(0));
        delta.replay(new TopicRecord().setTopicId(topicB).setName("b"));
        delta.replay(new PartitionRecord().setTopicId(topicB).setPartitionId(0));
        delta.replay(new TopicRecord().setTopicId(topicC).setName("c"));
        delta.replay(new PartitionRecord().setTopicId(topicC).setPartitionId(0));
        delta.replay(new TopicRecord().setTopicId(topicD).setName("d"));
        delta.replay(new PartitionRecord().setTopicId(topicD).setPartitionId(0));
        MetadataImage image = delta.apply(MetadataProvenance.EMPTY);

        // Create a delta which updates topic B, deletes topic D and creates topic E.
        delta = new MetadataDelta.Builder()
            .setImage(image)
            .build();
        delta.replay(new PartitionRecord().setTopicId(topicB).setPartitionId(2));
        delta.replay(new RemoveTopicRecord().setTopicId(topicD));
        delta.replay(new TopicRecord().setTopicId(topicE).setName("e"));
        delta.replay(new PartitionRecord().setTopicId(topicE).setPartitionId(1));
        image = delta.apply(MetadataProvenance.EMPTY);

        // Update metadata image with the delta.
        context.groupMetadataManager.onMetadataUpdate(new KRaftCoordinatorMetadataDelta(delta), new KRaftCoordinatorMetadataImage(image));

        // Verify the groups.
        List.of("group1", "group2", "group3", "group4").forEach(groupId -> {
            StreamsGroup group = context.groupMetadataManager.streamsGroup(groupId);
            assertTrue(group.hasMetadataExpired(context.time.milliseconds()), groupId);
        });

        List.of("group5").forEach(groupId -> {
            StreamsGroup group = context.groupMetadataManager.streamsGroup(groupId);
            assertFalse(group.hasMetadataExpired(context.time.milliseconds()));
        });

        // Verify image.
        assertEquals(new KRaftCoordinatorMetadataImage(image), context.groupMetadataManager.image());
    }

    @Test
    public void testStreamsGroupEndpointInformationOnlyWhenEpochGreater() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
                new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
                .withStreamsGroupTaskAssignors(List.of(assignor))
                .withMetadataImage(new MetadataImageBuilder()
                        .addTopic(fooTopicId, fooTopicName, 2)
                        .buildCoordinatorMetadataImage())
                .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
                .build();

        // Prepare new assignment for the group.
        assignor.prepareGroupAssignment(
                Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE, TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1))));

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result;

        // A full response should be sent back on joining.
        result = context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                        .setGroupId(groupId)
                        .setMemberId(memberId)
                        .setMemberEpoch(0)
                        .setRebalanceTimeoutMs(1500)
                        .setTopology(topology)
                        .setActiveTasks(List.of())
                        .setStandbyTasks(List.of())
                        .setWarmupTasks(List.of())
                        .setUserEndpoint(new StreamsGroupHeartbeatRequestData.Endpoint().setHost("localhost").setPort(9092))
                        .setEndpointInformationEpoch(0));

        StreamsGroupHeartbeatResponseData.EndpointToPartitions expectedEndpointToPartitions = new StreamsGroupHeartbeatResponseData.EndpointToPartitions()
            .setUserEndpoint(new StreamsGroupHeartbeatResponseData.Endpoint().setHost("localhost").setPort(9092))
            .setActivePartitions(List.of(new StreamsGroupHeartbeatResponseData.TopicPartition().setTopic("foo").setPartitions(List.of(0, 1))))
            .setStandbyPartitions(List.of());

        assertResponseEquals(
                new StreamsGroupHeartbeatResponseData()
                        .setMemberId(memberId)
                        .setMemberEpoch(2)
                        .setHeartbeatIntervalMs(5000)
                        .setActiveTasks(List.of(
                                new StreamsGroupHeartbeatResponseData.TaskIds()
                                        .setSubtopologyId(subtopology1)
                                        .setPartitions(List.of(0, 1))))
                        .setStandbyTasks(List.of())
                        .setWarmupTasks(List.of())
                        .setPartitionsByUserEndpoint(List.of(expectedEndpointToPartitions))
                        .setStatus(List.of()),
                result.response().data()
        );

        result = context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                        .setGroupId(groupId)
                        .setMemberId(memberId)
                        .setUserEndpoint(new StreamsGroupHeartbeatRequestData.Endpoint().setHost("localhost").setPort(9092))
                        .setMemberEpoch(result.response().data().memberEpoch())
                        .setEndpointInformationEpoch(result.response().data().endpointInformationEpoch()));

        assertNull(result.response().data().partitionsByUserEndpoint());
    }

    @Test
    public void testStreamsGroupEndpointInformationIncludesNewMember() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
                new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
                .withStreamsGroupTaskAssignors(List.of(assignor))
                .withMetadataImage(new MetadataImageBuilder()
                        .addTopic(fooTopicId, fooTopicName, 4)
                        .buildCoordinatorMetadataImage())
                .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
                .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNMENT_INTERVAL_MS_CONFIG, 0)
                .build();

        // Prepare assignment for first member
        assignor.prepareGroupAssignment(
                Map.of(memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE, TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1))));

        // First member joins
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                        .setGroupId(groupId)
                        .setMemberId(memberId1)
                        .setMemberEpoch(0)
                        .setRebalanceTimeoutMs(1500)
                        .setTopology(topology)
                        .setActiveTasks(List.of())
                        .setStandbyTasks(List.of())
                        .setWarmupTasks(List.of())
                        .setUserEndpoint(new StreamsGroupHeartbeatRequestData.Endpoint().setHost("host1").setPort(9092))
                        .setEndpointInformationEpoch(0));

        assertEquals(2, result.response().data().memberEpoch());

        // Prepare assignment for both members
        assignor.prepareGroupAssignment(
                Map.of(
                        memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE, TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1)),
                        memberId2, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE, TaskAssignmentTestUtil.mkTasks(subtopology1, 2, 3))
                ));

        // Second member joins
        result = context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                        .setGroupId(groupId)
                        .setMemberId(memberId2)
                        .setMemberEpoch(0)
                        .setRebalanceTimeoutMs(1500)
                        .setTopology(topology)
                        .setActiveTasks(List.of())
                        .setStandbyTasks(List.of())
                        .setWarmupTasks(List.of())
                        .setUserEndpoint(new StreamsGroupHeartbeatRequestData.Endpoint().setHost("host2").setPort(9093)));

        // The response should include endpoint information because the member's epoch (0) differs from the group's (1)
        assertNotNull(result.response().data().partitionsByUserEndpoint());
        List<StreamsGroupHeartbeatResponseData.EndpointToPartitions> endpointsList = result.response().data().partitionsByUserEndpoint();
        assertEquals(2, endpointsList.size(), "Should include both members in endpoint information");

        // Sort by port for consistent ordering
        endpointsList.sort(Comparator.comparingInt(e -> e.userEndpoint().port()));

        // Verify first member's endpoint
        StreamsGroupHeartbeatResponseData.EndpointToPartitions member1Endpoint = endpointsList.get(0);
        assertEquals("host1", member1Endpoint.userEndpoint().host());
        assertEquals(9092, member1Endpoint.userEndpoint().port());
        assertEquals(1, member1Endpoint.activePartitions().size());
        StreamsGroupHeartbeatResponseData.TopicPartition member1Topic = member1Endpoint.activePartitions().get(0);
        assertEquals("foo", member1Topic.topic());
        List<Integer> member1Partitions = new ArrayList<>(member1Topic.partitions());
        Collections.sort(member1Partitions);
        assertEquals(List.of(0, 1), member1Partitions);

        // Verify second member's endpoint (the new member)
        StreamsGroupHeartbeatResponseData.EndpointToPartitions member2Endpoint = endpointsList.get(1);
        assertEquals("host2", member2Endpoint.userEndpoint().host());
        assertEquals(9093, member2Endpoint.userEndpoint().port());
        assertEquals(1, member2Endpoint.activePartitions().size());
        StreamsGroupHeartbeatResponseData.TopicPartition member2Topic = member2Endpoint.activePartitions().get(0);
        assertEquals("foo", member2Topic.topic());
        List<Integer> member2Partitions = new ArrayList<>(member2Topic.partitions());
        Collections.sort(member2Partitions);
        assertEquals(List.of(2, 3), member2Partitions);
    }

    @Test
    public void testStreamsGroupEpochIncreaseWithAssignmentConfigChanges() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();

        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_NUM_STANDBY_REPLICAS_CONFIG, 0)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
                .withMember(streamsGroupMemberBuilderWithDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setAssignedTasks(TaskAssignmentTestUtil.mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)))
                    .build())
                .withTargetAssignment(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5)))
                .withTargetAssignmentEpoch(10)
                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
                .withValidatedTopologyEpoch(0)
            )
            .build();

        // Change the assignment config
        Properties newConfig = new Properties();
        newConfig.put(GroupConfig.STREAMS_NUM_STANDBY_REPLICAS_CONFIG, "2");
        context.groupConfigManager.updateGroupConfig(groupId, newConfig);

        assignor.prepareGroupAssignment(
            Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3, 4, 5),
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
        );

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(10)
                .setActiveTasks(List.of(new StreamsGroupHeartbeatRequestData.TaskIds()
                    .setSubtopologyId(subtopology1)
                    .setPartitions(List.of(0, 1, 2))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology1)
                        .setPartitions(List.of(0, 1, 2))))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of()),
            result.response().data()
        );

        // Find the StreamsGroupMetadata record
        CoordinatorRecord metadataRecord = result.records().stream()
            .filter(record -> record.key() instanceof StreamsGroupMetadataKey)
            .findFirst()
            .orElse(null);

        assertNotNull(metadataRecord, "Expected a StreamsGroupMetadata record");
        // Verify the metadata record contains the updated assignment config
        StreamsGroupMetadataValue metadataValue = (StreamsGroupMetadataValue) metadataRecord.value().message();
        assertEquals(11, metadataValue.epoch());

        // Verify the assignment config contains the updated value
        List<StreamsGroupMetadataValue.LastAssignmentConfig> assignmentConfigs = metadataValue.lastAssignmentConfigs();
        assertFalse(assignmentConfigs.isEmpty(), "Expected assignment configs to be present");

        StreamsGroupMetadataValue.LastAssignmentConfig standbyReplicasConfig = assignmentConfigs.stream()
            .filter(config -> "num.standby.replicas".equals(config.key()))
            .findFirst()
            .orElse(null);

        assertNotNull(standbyReplicasConfig, "Expected num.standby.replicas config to be present");
        assertEquals("2", standbyReplicasConfig.value());

        // Verify that group epoch was bumped
        StreamsGroup group = context.groupMetadataManager.streamsGroup(groupId);
        int newGroupEpoch = group.groupEpoch();
        assertEquals(11, newGroupEpoch);
        assertEquals("2", group.lastAssignmentConfigs().get("num.standby.replicas"));
    }

    @Test
    public void testStreamsGroupHeartbeatWithNonEmptyClassicGroup() {
        String classicGroupId = "classic-group-id";
        String memberId = Uuid.randomUuid().toString();

        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
        ClassicGroup classicGroup = new ClassicGroup(
            new LogContext(),
            classicGroupId,
            EMPTY,
            context.time
        );
        context.replay(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(classicGroup, classicGroup.groupAssignment()));

        context.groupMetadataManager.getOrMaybeCreateClassicGroup(classicGroupId, false).transitionTo(PREPARING_REBALANCE);
        assertThrows(GroupIdNotFoundException.class, () ->
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(classicGroupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(12000)
                    .setTopology(topology)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of())));
    }

    @Test
    public void testStreamsGroupHeartbeatWithEmptyClassicGroup() {
        String classicGroupId = "classic-group-id";
        String memberId = Uuid.randomUuid().toString();
        String fooTopicName = "foo";
        String subtopology1 = "subtopology1";
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();
        ClassicGroup classicGroup = new ClassicGroup(
            new LogContext(),
            classicGroupId,
            EMPTY,
            context.time
        );
        context.replay(GroupCoordinatorRecordHelpers.newGroupMetadataRecord(classicGroup, classicGroup.groupAssignment()));

        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(classicGroupId)
                .setMemberId(memberId)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(12000)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        StreamsGroupMember expectedMember = StreamsGroupMember.Builder.withDefaults(memberId)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setRebalanceTimeoutMs(5000)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setAssignedTasks(TasksTupleWithEpochs.EMPTY)
            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
            .setRebalanceTimeoutMs(12000)
            .setTopologyEpoch(0)
            .build();

        assertEquals(Errors.NONE.code(), result.response().data().errorCode());
        assertEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newGroupMetadataTombstoneRecord(classicGroupId),
                StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(classicGroupId, expectedMember),
                StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord(classicGroupId, topology),
                StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
                    classicGroupId,
                    2,
                    0,
                    -1,
                    new TreeMap<>(Map.of(
                        "num.standby.replicas", "0"
                    ))
                ),
                StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(classicGroupId, memberId, TasksTuple.EMPTY),
                StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(classicGroupId, 2, context.time.milliseconds()),
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(classicGroupId, expectedMember)
            ),
            result.records()
        );
        assertEquals(
            Group.GroupType.STREAMS,
            context.groupMetadataManager.streamsGroup(classicGroupId).type()
        );
    }

    @Test
    public void testClassicGroupJoinWithEmptyStreamsGroup() throws Exception {
        String streamsGroupId = "streams-group-id";
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroup(new StreamsGroupBuilder(streamsGroupId, 10))
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(streamsGroupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();
        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request, true);

        List<CoordinatorRecord> expectedRecords = List.of(
            StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataTombstoneRecord(streamsGroupId),
            StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord(streamsGroupId),
            StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecordTombstone(streamsGroupId)
        );

        assertEquals(Errors.MEMBER_ID_REQUIRED.code(), joinResult.joinFuture.get().errorCode());
        assertEquals(expectedRecords, joinResult.records.subList(0, expectedRecords.size()));
        assertEquals(
            Group.GroupType.CLASSIC,
            context.groupMetadataManager.getOrMaybeCreateClassicGroup(streamsGroupId, false).type()
        );
    }

    @Test
    public void testClassicGroupJoinWithNonEmptyStreamsGroup() throws Exception {
        String streamsGroupId = "streams-group-id";
        String memberId = Uuid.randomUuid().toString();
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroup(new StreamsGroupBuilder(streamsGroupId, 10)
                .withMember(StreamsGroupMember.Builder.withDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .build()))
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(streamsGroupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withDefaultProtocolTypeAndProtocols()
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);
        assertEquals(Errors.INCONSISTENT_GROUP_PROTOCOL.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testConsumerGroupDynamicConfigs() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addRacks()
                .buildCoordinatorMetadataImage())
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result =
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(90000)
                    .setSubscribedTopicNames(List.of("foo"))
                    .setTopicPartitions(List.of()));
        assertEquals(2, result.response().memberEpoch());

        // Verify heartbeat interval
        assertEquals(5000, result.response().heartbeatIntervalMs());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().heartbeatIntervalMs())
        );

        // Dynamic update group config
        Properties newGroupConfig = new Properties();
        newGroupConfig.put(CONSUMER_SESSION_TIMEOUT_MS_CONFIG, 50000);
        newGroupConfig.put(CONSUMER_HEARTBEAT_INTERVAL_MS_CONFIG, 10000);
        context.updateGroupConfig(groupId, newGroupConfig);

        // Session timer is rescheduled on second heartbeat.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().memberEpoch()));
        assertEquals(2, result.response().memberEpoch());

        // Verify heartbeat interval
        assertEquals(10000, result.response().heartbeatIntervalMs());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 50000);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().heartbeatIntervalMs())
        );

        // Session timer is cancelled on leave.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH));
        assertEquals(LEAVE_GROUP_MEMBER_EPOCH, result.response().memberEpoch());

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
        context.assertNoRebalanceTimeout(groupId, memberId);
    }

    @Test
    public void testShareGroupDynamicConfigs() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("simple");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addRacks()
                .buildCoordinatorMetadataImage())
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(image.emptyDelta(), image);

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result =
            context.shareGroupHeartbeat(
                new ShareGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setSubscribedTopicNames(List.of("foo")));
        assertEquals(2, result.response().getKey().memberEpoch());

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(
                fooTopicId,
                Set.of(0, 1, 2, 3, 4, 5)
            ),
            groupId,
            2,
            true
        );

        // Verify heartbeat interval
        assertEquals(5000, result.response().getKey().heartbeatIntervalMs());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 45000);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().getKey().heartbeatIntervalMs())
        );

        // Dynamic update group config
        Properties newGroupConfig = new Properties();
        newGroupConfig.put(SHARE_SESSION_TIMEOUT_MS_CONFIG, 50000);
        newGroupConfig.put(SHARE_HEARTBEAT_INTERVAL_MS_CONFIG, 10000);
        context.updateGroupConfig(groupId, newGroupConfig);

        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializedTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(fooTopicId)
                        .setTopicName(fooTopicName)
                        .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                ))
                .setDeletingTopics(List.of())
        );

        // Session timer is rescheduled on second heartbeat.
        result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().getKey().memberEpoch()));
        assertEquals(2, result.response().getKey().memberEpoch());

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(),
            "",
            0,
            false
        );

        // Verify heartbeat interval
        assertEquals(10000, result.response().getKey().heartbeatIntervalMs());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 50000);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().getKey().heartbeatIntervalMs())
        );

        // Session timer is cancelled on leave.
        result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH));
        assertEquals(LEAVE_GROUP_MEMBER_EPOCH, result.response().getKey().memberEpoch());

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(),
            "",
            0,
            false
        );

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
        context.assertNoRebalanceTimeout(groupId, memberId);
    }

    @Test
    public void testStreamsGroupDynamicConfigs() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addRacks()
                .buildCoordinatorMetadataImage())
                .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        assignor.prepareGroupAssignment(
            Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2))));

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result =
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(10000)
                    .setTopology(topology)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of()));
        assertEquals(2, result.response().data().memberEpoch());
        assertEquals(
            Map.of("num.standby.replicas", "0"),
            assignor.lastPassedAssignmentConfigs()
        );

        // Verify heartbeat interval
        assertEquals(GroupCoordinatorConfig.STREAMS_GROUP_HEARTBEAT_INTERVAL_MS_DEFAULT, result.response().data().heartbeatIntervalMs());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, GroupCoordinatorConfig.STREAMS_GROUP_SESSION_TIMEOUT_MS_DEFAULT);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().data().heartbeatIntervalMs())
        );

        // Dynamic update group config
        Properties newGroupConfig = new Properties();
        newGroupConfig.put(STREAMS_SESSION_TIMEOUT_MS_CONFIG, 50000);
        newGroupConfig.put(STREAMS_HEARTBEAT_INTERVAL_MS_CONFIG, 10000);
        newGroupConfig.put(STREAMS_NUM_STANDBY_REPLICAS_CONFIG, 2);
        context.updateGroupConfig(groupId, newGroupConfig);

        // Session timer is rescheduled on second heartbeat, new assignment with new parameter is calculated.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().data().memberEpoch())
                .setRackId("bla"));

        // Verify heartbeat interval
        assertEquals(10000, result.response().data().heartbeatIntervalMs());

        // Verify that there is a session time.
        context.assertSessionTimeout(groupId, memberId, 50000);

        // Verify that the new number of standby replicas is used
        assertEquals(
            Map.of("num.standby.replicas", "2"),
            assignor.lastPassedAssignmentConfigs()
        );

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().data().heartbeatIntervalMs())
        );

        // Session timer is cancelled on leave.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH));
        assertEquals(LEAVE_GROUP_MEMBER_EPOCH, result.response().data().memberEpoch());

        // Verify that there are no timers.
        context.assertNoSessionTimeout(groupId, memberId);
        context.assertNoRebalanceTimeout(groupId, memberId);
    }

    @Test
    public void testDynamicBrokerAndGroupConfigs() {
        testDynamicBrokerAndGroupConfig(
            GroupMetadataManager::consumerGroupAssignmentIntervalMs,
            GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNMENT_INTERVAL_MS_CONFIG,
            GroupConfig.CONSUMER_ASSIGNMENT_INTERVAL_MS_CONFIG,
            2000, 1500, 1000, 500
        );
        testDynamicBrokerAndGroupConfig(
            GroupMetadataManager::shareGroupAssignmentIntervalMs,
            GroupCoordinatorConfig.SHARE_GROUP_ASSIGNMENT_INTERVAL_MS_CONFIG,
            GroupConfig.SHARE_ASSIGNMENT_INTERVAL_MS_CONFIG,
            2000, 1500, 1000, 500
        );
        testDynamicBrokerAndGroupConfig(
            GroupMetadataManager::streamsGroupAssignmentIntervalMs,
            GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNMENT_INTERVAL_MS_CONFIG,
            GroupConfig.STREAMS_ASSIGNMENT_INTERVAL_MS_CONFIG,
            2000, 1500, 1000, 500
        );
    }

    private <V> void testDynamicBrokerAndGroupConfig(
        BiFunction<GroupMetadataManager, String, V> getValue,
        String brokerConfigKey,
        String groupConfigKey,
        V initial,
        V brokerOverride1,
        V brokerOverride2,
        V groupOverride
    ) {
        class DynamicConfig extends AbstractConfig {
            private final Map<String, Object> overrides = new HashMap<>();

            DynamicConfig(Map<?, ?> props) {
                super(
                    Utils.mergeConfigs(List.of(
                        GroupCoordinatorConfig.CONFIG_DEF,
                        ShareGroupConfig.CONFIG_DEF
                    )),
                    props,
                    false
                );
            }

            @Override
            protected Object get(String key) {
                return overrides.getOrDefault(key, super.get(key));
            }

            void put(String key, Object value) {
                overrides.put(key, value);
            }
        }

        MockTime time = new MockTime(0, 0, 0);
        DynamicConfig kafkaConfig = new DynamicConfig(Map.of(brokerConfigKey, initial));
        GroupCoordinatorConfig groupCoordinatorConfig = new GroupCoordinatorConfig(kafkaConfig);
        ShareGroupConfig shareGroupConfig = new ShareGroupConfig(kafkaConfig);
        GroupConfigManager groupConfigManager = new GroupConfigManager(
            groupCoordinatorConfig.extractGroupConfigMap(shareGroupConfig),
            groupCoordinatorConfig,
            shareGroupConfig
        );
        GroupMetadataManager groupMetadataManager = new GroupMetadataManager.Builder()
            .withTime(time)
            .withTimer(new MockCoordinatorTimer<>(time))
            .withExecutor(new MockCoordinatorExecutor<>())
            .withConfig(groupCoordinatorConfig)
            .withGroupCoordinatorMetricsShard(mock(GroupCoordinatorMetricsShard.class))
            .withGroupConfigManager(groupConfigManager)
            .build();

        String groupId = "test-group";
        assertEquals(initial, getValue.apply(groupMetadataManager, groupId));

        // Set broker-level override.
        kafkaConfig.put(brokerConfigKey, brokerOverride1);
        assertEquals(brokerOverride1, getValue.apply(groupMetadataManager, groupId));

        // Create a group config entry.
        Properties groupConfig = new Properties();
        groupConfig.put(GroupConfig.CONSUMER_SESSION_TIMEOUT_MS_CONFIG, 2000);
        groupConfigManager.updateGroupConfig(groupId, groupConfig);

        // Check that broker-level overrides still work. The group config must not bake in the value.
        assertEquals(brokerOverride1, getValue.apply(groupMetadataManager, groupId));
        kafkaConfig.put(brokerConfigKey, brokerOverride2);
        assertEquals(brokerOverride2, getValue.apply(groupMetadataManager, groupId));

        // Set group-level override.
        groupConfig.put(groupConfigKey, String.valueOf(groupOverride));
        groupConfigManager.updateGroupConfig(groupId, groupConfig);
        assertEquals(groupOverride, getValue.apply(groupMetadataManager, groupId));

        // Remove group-level override.
        groupConfig.remove(groupConfigKey);
        groupConfigManager.updateGroupConfig(groupId, groupConfig);
        assertEquals(brokerOverride2, getValue.apply(groupMetadataManager, groupId));
    }

    @Test
    public void testConsumerGroupEvaluatedConfigs() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addRacks()
                .buildCoordinatorMetadataImage())
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result =
            context.consumerGroupHeartbeat(
                new ConsumerGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(90000)
                    .setSubscribedTopicNames(List.of("foo"))
                    .setTopicPartitions(List.of()));
        assertEquals(2, result.response().memberEpoch());

        // Verify default heartbeat interval and session timeout before config update.
        assertEquals(GroupCoordinatorConfig.CONSUMER_GROUP_HEARTBEAT_INTERVAL_MS_DEFAULT,
            result.response().heartbeatIntervalMs());
        context.assertSessionTimeout(groupId, memberId,
            GroupCoordinatorConfig.CONSUMER_GROUP_SESSION_TIMEOUT_MS_DEFAULT);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().heartbeatIntervalMs())
        );

        // Dynamic update group config with out-of-range values.
        // Session timeout 70000 exceeds max 60000; heartbeat interval 1 is below min 5000.
        Properties newGroupConfig = new Properties();
        newGroupConfig.put(CONSUMER_SESSION_TIMEOUT_MS_CONFIG, 70000);
        newGroupConfig.put(CONSUMER_HEARTBEAT_INTERVAL_MS_CONFIG, 1);
        context.updateGroupConfig(groupId, newGroupConfig);

        // Session timer is rescheduled on second heartbeat.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().memberEpoch()));
        assertEquals(2, result.response().memberEpoch());

        // Verify heartbeat interval is evaluated to min.
        assertEquals(GroupCoordinatorConfig.CONSUMER_GROUP_MIN_HEARTBEAT_INTERVAL_MS_DEFAULT,
            result.response().heartbeatIntervalMs());

        // Verify session timeout is evaluated to max.
        context.assertSessionTimeout(groupId, memberId,
            GroupCoordinatorConfig.CONSUMER_GROUP_MAX_SESSION_TIMEOUT_MS_DEFAULT);
    }

    @Test
    public void testShareGroupEvaluatedConfigs() {
        String groupId = "fooup";
        // Use a static member id as it makes the test easier.
        String memberId = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("simple");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addRacks()
                .buildCoordinatorMetadataImage())
            .build();

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(memberId, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )))
        ));

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(image.emptyDelta(), image);

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result =
            context.shareGroupHeartbeat(
                new ShareGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setSubscribedTopicNames(List.of("foo")));
        assertEquals(2, result.response().getKey().memberEpoch());

        // Verify default heartbeat interval and session timeout before config update.
        assertEquals(GroupCoordinatorConfig.SHARE_GROUP_HEARTBEAT_INTERVAL_MS_DEFAULT,
            result.response().getKey().heartbeatIntervalMs());
        context.assertSessionTimeout(groupId, memberId,
            GroupCoordinatorConfig.SHARE_GROUP_SESSION_TIMEOUT_MS_DEFAULT);

        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().getKey().heartbeatIntervalMs())
        );

        // Dynamic update group config with out-of-range values.
        // Session timeout 70000 exceeds max 60000; heartbeat interval 1 is below min 5000.
        Properties newGroupConfig = new Properties();
        newGroupConfig.put(SHARE_SESSION_TIMEOUT_MS_CONFIG, 70000);
        newGroupConfig.put(SHARE_HEARTBEAT_INTERVAL_MS_CONFIG, 1);
        context.updateGroupConfig(groupId, newGroupConfig);

        // Replay ShareGroupStatePartitionMetadata required before second heartbeat.
        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializedTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(fooTopicId)
                        .setTopicName(fooTopicName)
                        .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                ))
                .setDeletingTopics(List.of())
        );

        // Session timer is rescheduled on second heartbeat.
        result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().getKey().memberEpoch()));
        assertEquals(2, result.response().getKey().memberEpoch());

        // Verify heartbeat interval is evaluated to min.
        assertEquals(GroupCoordinatorConfig.SHARE_GROUP_MIN_HEARTBEAT_INTERVAL_MS_DEFAULT,
            result.response().getKey().heartbeatIntervalMs());

        // Verify session timeout is evaluated to max.
        context.assertSessionTimeout(groupId, memberId,
            GroupCoordinatorConfig.SHARE_GROUP_MAX_SESSION_TIMEOUT_MS_DEFAULT);
    }

    @Test
    public void testStreamsGroupEvaluatedConfigs() {
        String groupId = "fooup";
        String memberId = Uuid.randomUuid().toString();
        String subtopology1 = "subtopology1";
        String fooTopicName = "foo";
        Uuid fooTopicId = Uuid.randomUuid();
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .addRacks()
                .buildCoordinatorMetadataImage())
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .build();

        assignor.prepareGroupAssignment(
            Map.of(memberId, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2))));

        // Session timer is scheduled on first heartbeat.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result =
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
                    .setMemberEpoch(0)
                    .setRebalanceTimeoutMs(10000)
                    .setTopology(topology)
                    .setActiveTasks(List.of())
                    .setStandbyTasks(List.of())
                    .setWarmupTasks(List.of()));
        assertEquals(2, result.response().data().memberEpoch());

        // Verify default heartbeat interval, session timeout, and num.standby.replicas before config update.
        assertEquals(GroupCoordinatorConfig.STREAMS_GROUP_HEARTBEAT_INTERVAL_MS_DEFAULT,
            result.response().data().heartbeatIntervalMs());
        context.assertSessionTimeout(groupId, memberId,
            GroupCoordinatorConfig.STREAMS_GROUP_SESSION_TIMEOUT_MS_DEFAULT);
        assertEquals(
            Map.of(
                "num.standby.replicas", String.valueOf(GroupCoordinatorConfig.STREAMS_GROUP_NUM_STANDBY_REPLICAS_DEFAULT)
            ),
            assignor.lastPassedAssignmentConfigs());
        // Advance time.
        assertEquals(
            List.of(),
            context.sleep(result.response().data().heartbeatIntervalMs())
        );

        // Dynamic update group config with out-of-range values.
        // Session timeout 70000 exceeds max 60000; heartbeat interval 1 is below min 5000;
        // num standby replicas 100 exceeds max 2.
        Properties newGroupConfig = new Properties();
        newGroupConfig.put(STREAMS_SESSION_TIMEOUT_MS_CONFIG, 70000);
        newGroupConfig.put(STREAMS_HEARTBEAT_INTERVAL_MS_CONFIG, 1);
        newGroupConfig.put(STREAMS_NUM_STANDBY_REPLICAS_CONFIG, 100);
        context.updateGroupConfig(groupId, newGroupConfig);

        // Session timer is rescheduled on second heartbeat, new assignment with evaluated parameter is calculated.
        result = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(result.response().data().memberEpoch())
                .setRackId("bla"));

        // Verify heartbeat interval is evaluated to min.
        assertEquals(GroupCoordinatorConfig.STREAMS_GROUP_MIN_HEARTBEAT_INTERVAL_MS_DEFAULT,
            result.response().data().heartbeatIntervalMs());

        // Verify session timeout is evaluated to max.
        context.assertSessionTimeout(groupId, memberId,
            GroupCoordinatorConfig.STREAMS_GROUP_MAX_SESSION_TIMEOUT_MS_DEFAULT);

        // Verify that the number of standby replicas is evaluated to max.
        assertEquals(
            Map.of(
                "num.standby.replicas", String.valueOf(GroupCoordinatorConfig.STREAMS_GROUP_MAX_STANDBY_REPLICAS_DEFAULT)
            ),
            assignor.lastPassedAssignmentConfigs());
    }

    @Test
    public void testReplayConsumerGroupMemberMetadata() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        ConsumerGroupMember member = new ConsumerGroupMember.Builder("member")
            .setClientId("clientid")
            .setClientHost("clienthost")
            .setServerAssignorName("range")
            .setRackId("rackid")
            .setSubscribedTopicNames(List.of("foo"))
            .build();

        // The group and the member are created if they do not exist.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord("foo", member));
        assertEquals(member, context.groupMetadataManager.consumerGroup("foo").getOrMaybeCreateMember("member", false));
    }

    @Test
    public void testReplayConsumerGroupMemberMetadataTombstone() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group still exists but the member is already gone. Replaying the
        // ConsumerGroupMemberMetadata tombstone should be a no-op.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord("foo", 10, 0));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord("foo", "m1"));
        assertThrows(UnknownMemberIdException.class, () -> context.groupMetadataManager.consumerGroup("foo").getOrMaybeCreateMember("m1", false));

        // The group may not exist at all. Replaying the ConsumerGroupMemberMetadata tombstone
        // should a no-op.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord("bar", "m1"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.consumerGroup("bar"));
    }

    @Test
    public void testReplayConsumerGroupMetadata() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group is created if it does not exist.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord("foo", 10, 0));
        assertEquals(10, context.groupMetadataManager.consumerGroup("foo").groupEpoch());
    }

    @Test
    public void testReplayConsumerGroupMetadataTombstone() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group may not exist at all. Replaying the ConsumerGroupMetadata tombstone
        // should be a no-op.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochTombstoneRecord("foo"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.consumerGroup("foo"));
    }

    @Test
    public void testReplayConsumerGroupPartitionMetadata() {
        String groupId = "foo";
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group is created if it does not exist.
        ConsumerGroupPartitionMetadataValue consumerGroupPartitionMetadataValue = new ConsumerGroupPartitionMetadataValue();
        consumerGroupPartitionMetadataValue.topics().add(new ConsumerGroupPartitionMetadataValue.TopicMetadata()
            .setTopicId(Uuid.randomUuid())
            .setTopicName("bar")
            .setNumPartitions(10));
        context.replay(CoordinatorRecord.record(
            new ConsumerGroupPartitionMetadataKey().setGroupId(groupId),
            new ApiMessageAndVersion(consumerGroupPartitionMetadataValue, (short) 0)
        ));
        assertTrue(context.groupMetadataManager.consumerGroup(groupId).hasSubscriptionMetadataRecord());
    }

    @Test
    public void testReplayConsumerGroupPartitionMetadataTombstone() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group may not exist at all. Replaying the ConsumerGroupPartitionMetadata tombstone
        // should be a no-op.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord("foo"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.consumerGroup("foo"));
    }

    @Test
    public void testReplayConsumerGroupPartitionMetadataTombstoneWithExistentGroup() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConsumerGroup(new ConsumerGroupBuilder("foo", 10))
            .build();

        // The group exists. Replaying the ConsumerGroupPartitionMetadata tombstone
        // doesn't set addSubscriptionMetadataTombstoneRecord flag.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupSubscriptionMetadataTombstoneRecord("foo"));
        assertFalse(context.groupMetadataManager.consumerGroup("foo").hasSubscriptionMetadataRecord());
    }

    @Test
    public void testReplayConsumerGroupTargetAssignmentMember() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        Map<Uuid, Set<Integer>> assignment = mkAssignment(
            mkTopicAssignment(Uuid.randomUuid(), 0, 1, 2)
        );

        // The group is created if it does not exist.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord("foo", "m1", assignment));
        assertEquals(assignment, context.groupMetadataManager.consumerGroup("foo").targetAssignment("m1").partitions());
    }

    @Test
    public void testReplayConsumerGroupTargetAssignmentMemberTombstone() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group may not exist at all. Replaying the ConsumerGroupTargetAssignmentMember tombstone
        // should be a no-op.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord("foo", "m1"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.consumerGroup("foo"));
    }

    @Test
    public void testReplayConsumerGroupTargetAssignmentMetadata() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group is created if it does not exist.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord("foo", 10, 12345L));
        assertEquals(10, context.groupMetadataManager.consumerGroup("foo").assignmentEpoch());
        assertEquals(12345L, context.groupMetadataManager.consumerGroup("foo").assignmentTimestamp());
    }

    @Test
    public void testReplayConsumerGroupTargetAssignmentMetadataTombstone() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group may not exist at all. Replaying the ConsumerGroupTargetAssignmentMetadata tombstone
        // should be a no-op.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataTombstoneRecord("foo"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.consumerGroup("foo"));
    }

    @Test
    public void testReplayConsumerGroupTargetAssignmentMetadataTombstoneExisting() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // Create the group by replaying a value record.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord("foo", 10, 12345L));
        assertEquals(10, context.groupMetadataManager.consumerGroup("foo").assignmentEpoch());
        assertEquals(12345L, context.groupMetadataManager.consumerGroup("foo").assignmentTimestamp());

        // Replay the tombstone. It should reset both the epoch and the timestamp.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataTombstoneRecord("foo"));
        assertEquals(-1, context.groupMetadataManager.consumerGroup("foo").assignmentEpoch());
        assertEquals(0L, context.groupMetadataManager.consumerGroup("foo").assignmentTimestamp());
    }

    @Test
    public void testReplayConsumerGroupCurrentMemberAssignment() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        Uuid topicId = Uuid.randomUuid();
        ConsumerGroupMember member = new ConsumerGroupMember.Builder("member")
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(topicId, 0, 1, 2)), 10))
            .build();

        // The group and the member are created if they do not exist.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord("bar", member));
        assertEquals(member, context.groupMetadataManager.consumerGroup("bar").getOrMaybeCreateMember("member", false));
    }

    @Test
    public void testReplayConsumerGroupCurrentMemberAssignmentTombstone() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group still exists but the member is already gone. Replaying the
        // ConsumerGroupCurrentMemberAssignment tombstone should be a no-op.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord("foo", 10, 0));
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord("foo", "m1"));
        assertThrows(UnknownMemberIdException.class, () -> context.groupMetadataManager.consumerGroup("foo").getOrMaybeCreateMember("m1", false));

        // The group may not exist at all. Replaying the ConsumerGroupCurrentMemberAssignment tombstone
        // should be a no-op.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord("bar", "m1"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.consumerGroup("bar"));
    }

    @Test
    public void testReplayShareGroupTargetAssignmentMetadata() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group is created if it does not exist.
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentMetadataRecord("foo", 10, 12345L));
        assertEquals(10, context.groupMetadataManager.shareGroup("foo").assignmentEpoch());
        assertEquals(12345L, context.groupMetadataManager.shareGroup("foo").assignmentTimestamp());
    }

    @Test
    public void testReplayShareGroupTargetAssignmentMetadataTombstone() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group may not exist at all. Replaying the ShareGroupTargetAssignmentMetadata tombstone
        // should be a no-op.
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentMetadataTombstoneRecord("foo"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.shareGroup("foo"));
    }

    @Test
    public void testReplayShareGroupTargetAssignmentMetadataTombstoneExisting() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // Create the group by replaying a value record.
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentMetadataRecord("foo", 10, 12345L));
        assertEquals(10, context.groupMetadataManager.shareGroup("foo").assignmentEpoch());
        assertEquals(12345L, context.groupMetadataManager.shareGroup("foo").assignmentTimestamp());

        // Replay the tombstone. It should reset both the epoch and the timestamp.
        context.replay(GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentMetadataTombstoneRecord("foo"));
        assertEquals(-1, context.groupMetadataManager.shareGroup("foo").assignmentEpoch());
        assertEquals(0L, context.groupMetadataManager.shareGroup("foo").assignmentTimestamp());
    }

    @Test
    public void testReplayStreamsGroupMemberMetadata() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        StreamsGroupMember member = new StreamsGroupMember.Builder("member")
            .setClientId("clientid")
            .setClientHost("clienthost")
            .setRackId("rackid")
            .setInstanceId("instanceid")
            .setRebalanceTimeoutMs(1000)
            .setTopologyEpoch(10)
            .setProcessId("processid")
            .setUserEndpoint(new Endpoint().setHost("localhost").setPort(9999))
            .setClientTags(Map.of("key", "value"))
            .build();

        // The group and the member are created if they do not exist.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord("foo", member));
        assertEquals(member, context.groupMetadataManager.streamsGroup("foo").getMemberOrThrow("member"));
    }

    @Test
    public void testReplayStreamsGroupMemberMetadataWithSimpleClassicGroup() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // A simple classic group is created when replaying offset commits without a group.
        // This simulates the scenario where offset commit records are replayed before streams
        // group records after log compaction has cleaned up the group metadata tombstone.
        context.groupMetadataManager.getOrMaybeCreateClassicGroup("foo", true);

        StreamsGroupMember member = new StreamsGroupMember.Builder("member")
            .setClientId("clientid")
            .setClientHost("clienthost")
            .setRackId("rackid")
            .setInstanceId("instanceid")
            .setRebalanceTimeoutMs(1000)
            .setTopologyEpoch(10)
            .setProcessId("processid")
            .setUserEndpoint(new Endpoint().setHost("localhost").setPort(9999))
            .setClientTags(Map.of("key", "value"))
            .build();

        // The simple classic group should be replaced by a streams group.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord("foo", member));
        assertEquals(member, context.groupMetadataManager.streamsGroup("foo").getMemberOrThrow("member"));
    }

    @Test
    public void testReplayStreamsGroupMemberMetadataTombstoneNotExisting() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group still exists but the member is already gone. Replaying the
        // StreamsGroupMemberMetadata tombstone should be a no-op.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord("foo", 10, 0, 0, Map.of("num.standby.replicas", "0")));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord("foo", "m1"));
        assertThrows(UnknownMemberIdException.class, () -> context.groupMetadataManager.streamsGroup("foo").getMemberOrThrow("m1"));

        // The group may not exist at all. Replaying the StreamsGroupMemberMetadata tombstone
        // should be a no-op.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord("bar", "m1"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.streamsGroup("bar"));
    }

    @Test
    public void testReplayStreamsGroupMemberMetadataTombstoneExisting() {
        final TasksTuple tasks =
            new TasksTuple(
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 0, 1, 2)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 3, 4, 5)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 6, 7, 8))
            );
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroup(
                new StreamsGroupBuilder("foo", 10)
                    .withMember(streamsGroupMemberBuilderWithDefaults("m1").build())
                    .withTargetAssignment("m1", tasks)
            )
            .build();

        IllegalStateException e = assertThrows(IllegalStateException.class,
            () -> context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord("foo", "m1")));
        assertEquals("Received a tombstone record to delete member m1 but did not receive "
                + "StreamsGroupCurrentMemberAssignmentValue tombstone.",
            e.getMessage());

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord("foo", "m1"));

        IllegalStateException e2 = assertThrows(IllegalStateException.class,
            () -> context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord("foo", "m1")));
        assertEquals("Received a tombstone record to delete member m1 but did not receive "
                + "StreamsGroupTargetAssignmentMetadataValue tombstone.",
            e2.getMessage());

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord("foo", "m1"));

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord("foo", "m1"));

        assertFalse(context.groupMetadataManager.streamsGroup("foo").hasMember("m1"));
    }

    @Test
    public void testReplayStreamsGroupMetadata() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group is created if it does not exist.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord("foo", 10, 0, 0, Map.of("num.standby.replicas", "0")));
        assertEquals(10, context.groupMetadataManager.streamsGroup("foo").groupEpoch());
    }

    @Test
    public void testReplayStreamsGroupEpochTombstoneNotExisting() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group may not exist at all. Replaying the StreamsGroupMetadata tombstone
        // should be a no-op.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord("foo"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.streamsGroup("foo"));
    }

    @Test
    public void testReplayStreamsGroupEpochTombstoneExisting() {
        final TasksTuple tasks =
            new TasksTuple(
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 0, 1, 2)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 3, 4, 5)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 6, 7, 8))
            );
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroup(
                new StreamsGroupBuilder("foo", 10)
                    .withTargetAssignmentEpoch(10)
                    .withMember(streamsGroupMemberBuilderWithDefaults("m1").build())
                    .withTargetAssignment("m1", tasks)
            )
            .build();

        IllegalStateException e = assertThrows(IllegalStateException.class,
            () -> context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord("foo")));
        assertEquals("Received a tombstone record to delete group foo but the group still has 1 members.",
            e.getMessage());

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord("foo", "m1"));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord("foo", "m1"));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord("foo", "m1"));

        IllegalStateException e2 = assertThrows(IllegalStateException.class,
            () -> context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord("foo")));
        assertEquals("Received a tombstone record to delete group foo but did not receive StreamsGroupTargetAssignmentMetadataValue tombstone.",
            e2.getMessage());

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataTombstoneRecord("foo"));

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord("foo"));

        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.streamsGroup("foo"));
    }

    @Test
    public void testReplayStreamsGroupTargetAssignmentMember() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group is created if it does not exist.
        final TasksTuple tasks =
            new TasksTuple(
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 0, 1, 2)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 3, 4, 5)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 6, 7, 8))
            );
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord("foo", "m1", tasks));
        assertEquals(tasks.activeTasks(), context.groupMetadataManager.streamsGroup("foo").targetAssignment("m1").activeTasks());
        assertEquals(tasks.standbyTasks(), context.groupMetadataManager.streamsGroup("foo").targetAssignment("m1").standbyTasks());
        assertEquals(tasks.warmupTasks(), context.groupMetadataManager.streamsGroup("foo").targetAssignment("m1").warmupTasks());
    }

    @Test
    public void testReplayStreamsGroupTargetAssignmentMemberTombstoneNonExisting() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group may not exist at all. Replaying the StreamsGroupTargetAssignmentMember tombstone
        // should be a no-op.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord("foo", "m1"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.streamsGroup("foo"));
    }

    @Test
    public void testReplayStreamsGroupTargetAssignmentMemberTombstoneExisting() {
        final TasksTuple tasks =
            new TasksTuple(
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 0, 1, 2)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 3, 4, 5)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 6, 7, 8))
            );
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroup(new StreamsGroupBuilder("foo", 10).withTargetAssignment("m1", tasks))
            .build();

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord("foo", "m1"));

        assertTrue(context.groupMetadataManager.streamsGroup("foo").targetAssignment("m1").isEmpty());
    }

    @Test
    public void testReplayStreamsGroupTargetAssignmentMetadata() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group is created if it does not exist.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord("foo", 10, 12345L));
        assertEquals(10, context.groupMetadataManager.streamsGroup("foo").assignmentEpoch());
        assertEquals(12345L, context.groupMetadataManager.streamsGroup("foo").assignmentTimestamp());
    }

    @Test
    public void testReplayStreamsGroupTargetAssignmentMetadataTombstoneNotExisting() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group may not exist at all. Replaying the StreamsGroupTargetAssignmentMetadata tombstone
        // should be a no-op.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataTombstoneRecord("foo"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.streamsGroup("foo"));
    }

    @Test
    public void testReplayStreamsGroupTargetAssignmentMetadataTombstoneExisting() {
        final TasksTuple tasks =
            new TasksTuple(
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 0, 1, 2)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 3, 4, 5)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 6, 7, 8))
            );

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroup(
                new StreamsGroupBuilder("foo", 10)
                    .withTargetAssignmentEpoch(10)
                    .withTargetAssignmentTimestamp(12345L)
                    .withTargetAssignment("m1", tasks)
            )
            .build();

        IllegalStateException e = assertThrows(
            IllegalStateException.class,
            () -> context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataTombstoneRecord("foo"))
        );
        assertEquals("Received a tombstone record to delete target assignment of foo but the assignment still has 1 members.",
            e.getMessage());

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord("foo", "m1"));

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataTombstoneRecord("foo"));

        assertEquals(-1, context.groupMetadataManager.streamsGroup("foo").assignmentEpoch());
        assertEquals(0L, context.groupMetadataManager.streamsGroup("foo").assignmentTimestamp());
    }

    @Test
    public void testReplayStreamsGroupCurrentMemberAssignment() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        StreamsGroupMember member = new StreamsGroupMember.Builder("member")
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(9)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.UNRELEASED_TASKS)
            .setAssignedTasks(new TasksTupleWithEpochs(
                TaskAssignmentTestUtil.mkTasksPerSubtopologyWithCommonEpoch(10,
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 0, 1, 2)
                ),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(TaskAssignmentTestUtil.mkTasks("subtopology-1", 3, 4, 5)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(TaskAssignmentTestUtil.mkTasks("subtopology-1", 6, 7, 8))
            ))
            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
            .build();

        // The group and the member are created if they do not exist.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord("bar", member));
        assertEquals(member, context.groupMetadataManager.streamsGroup("bar").getMemberOrThrow("member"));
    }

    @Test
    public void testReplayStreamsGroupCurrentMemberAssignmentTombstoneNotExisting() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group still exists, but the member is already gone. Replaying the
        // StreamsGroupCurrentMemberAssignment tombstone should be a no-op.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord("foo", 10, 0, 0, Map.of("num.standby.replicas", "0")));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord("foo", "m1"));
        assertThrows(UnknownMemberIdException.class, () -> context.groupMetadataManager.streamsGroup("foo").getMemberOrThrow("m1"));

        // The group may not exist at all. Replaying the StreamsGroupCurrentMemberAssignment tombstone
        // should be a no-op.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord("bar", "m1"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.streamsGroup("bar"));
    }

    @Test
    public void testReplayStreamsGroupCurrentMemberAssignmentTombstoneExisting() {
        final TasksTupleWithEpochs tasks =
            new TasksTupleWithEpochs(
                TaskAssignmentTestUtil.mkTasksPerSubtopologyWithCommonEpoch(1,
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 0, 1, 2)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 3, 4, 5)),
                TaskAssignmentTestUtil.mkTasksPerSubtopology(
                    TaskAssignmentTestUtil.mkTasks("subtopology-1", 6, 7, 8))
            );
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroup(
                new StreamsGroupBuilder("foo", 10)
                    .withMember(
                        streamsGroupMemberBuilderWithDefaults("m1")
                            .setAssignedTasks(tasks)
                            .build()
                    )
            )
            .build();

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord("foo", "m1"));

        final StreamsGroupMember member = context.groupMetadataManager.streamsGroup("foo").getMemberOrThrow("m1");
        assertEquals(LEAVE_GROUP_MEMBER_EPOCH, member.memberEpoch());
        assertEquals(LEAVE_GROUP_MEMBER_EPOCH, member.previousMemberEpoch());
        assertTrue(member.assignedTasks().isEmpty());
        assertTrue(member.tasksPendingRevocation().isEmpty());
    }

    @Test
    public void testReplayStreamsGroupTopology() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        StreamsGroupTopologyValue topology = new StreamsGroupTopologyValue()
            .setEpoch(12)
            .setSubtopologies(
                List.of(
                    new StreamsGroupTopologyValue.Subtopology()
                        .setSubtopologyId("subtopology-1")
                        .setSourceTopics(List.of("source-topic"))
                        .setRepartitionSinkTopics(List.of("sink-topic"))
                )
            );

        // The group and the topology are created if they do not exist.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord("bar", topology));
        final Optional<StreamsTopology> actualTopology = context.groupMetadataManager.streamsGroup("bar").topology();
        assertTrue(actualTopology.isPresent(), "topology should be set");
        assertEquals(topology.epoch(), actualTopology.get().topologyEpoch());
        assertEquals(topology.subtopologies().size(), actualTopology.get().subtopologies().size());
        assertEquals(
            topology.subtopologies().iterator().next(),
            actualTopology.get().subtopologies().values().iterator().next()
        );
    }

    @Test
    public void testReplayStreamsGroupTopologyTombstoneNotExists() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group still exists, but the member is already gone. Replaying the
        // StreamsGroupTopology tombstone should be a no-op.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord("foo", 10, 0, 0, Map.of("num.standby.replicas", "0")));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecordTombstone("foo"));
        assertTrue(context.groupMetadataManager.streamsGroup("foo").topology().isEmpty());

        // The group may not exist at all. Replaying the StreamsGroupTopology tombstone
        // should be a no-op.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecordTombstone("bar"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.streamsGroup("bar"));
    }

    @Test
    public void testReplayStreamsGroupTopologyTombstoneExists() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withStreamsGroup(
                new StreamsGroupBuilder("foo", 10)
                    .withTopology(new StreamsTopology(10, Map.of()))
            )
            .build();

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecordTombstone("foo"));

        assertTrue(context.groupMetadataManager.streamsGroup("foo").topology().isEmpty());
    }

    @Test
    public void testConsumerGroupHeartbeatOnShareGroup() {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withShareGroup(new ShareGroupBuilder(groupId, 1)
                .withMember(new ShareGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .build())
                .withAssignment(memberId, mkAssignment())
                .withAssignmentEpoch(1))
            .build();

        assertThrows(GroupIdNotFoundException.class, () -> context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setMemberId(memberId)
                .setGroupId(groupId)
                .setMemberEpoch(0)
                .setServerAssignor("range")
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(List.of())));
    }

    @Test
    public void testClassicGroupJoinOnShareGroup() throws Exception {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withShareGroup(new ShareGroupBuilder(groupId, 1)
                .withMember(new ShareGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .build())
                .withAssignment(memberId, mkAssignment())
                .withAssignmentEpoch(1))
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("consumer")
            .withProtocols(new JoinGroupRequestProtocolCollection(0))
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.INCONSISTENT_GROUP_PROTOCOL.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testClassicGroupSyncOnShareGroup() throws Exception {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withShareGroup(new ShareGroupBuilder(groupId, 1)
                .withMember(new ShareGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .build())
                .withAssignment(memberId, mkAssignment())
                .withAssignmentEpoch(1))
            .build();

        SyncGroupRequestData request = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId(groupId)
            .withGenerationId(1)
            .withMemberId(memberId)
            .build();

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(request);

        assertTrue(syncResult.records.isEmpty());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), syncResult.syncFuture.get().errorCode());
    }

    @Test
    public void testClassicGroupLeaveOnShareGroup() throws Exception {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
                .withShareGroup(new ShareGroupBuilder(groupId, 1)
                    .withMember(new ShareGroupMember.Builder(memberId)
                        .setState(MemberState.STABLE)
                        .setMemberEpoch(1)
                        .setPreviousMemberEpoch(0)
                        .setClientId(DEFAULT_CLIENT_ID)
                        .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                        .setSubscribedTopicNames(List.of("foo"))
                        .build())
                .withAssignment(memberId, mkAssignment())
                .withAssignmentEpoch(1))
                .build();

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
            .setGroupId(groupId)
            .setMembers(List.of(
                new MemberIdentity()
                    .setMemberId(memberId)))));
    }

    @Test
    public void testConsumerGroupDescribeOnShareGroup() {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        MockPartitionAssignor assignor = new MockPartitionAssignor("share");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withShareGroup(new ShareGroupBuilder(groupId, 1)
                .withMember(new ShareGroupMember.Builder(memberId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo"))
                    .build())
                .withAssignment(memberId, mkAssignment())
                .withAssignmentEpoch(1))
            .build();

        List<ConsumerGroupDescribeResponseData.DescribedGroup> expected = List.of(
            new ConsumerGroupDescribeResponseData.DescribedGroup()
                .setGroupId(groupId)
                .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code())
                .setErrorMessage("Group " + groupId + " is not a consumer group.")
        );

        List<ConsumerGroupDescribeResponseData.DescribedGroup> actual = context.sendConsumerGroupDescribe(List.of(groupId));
        assertEquals(expected, actual);
    }

    @Test
    public void testShareGroupHeartbeatOnConsumerGroup() {
        String groupId = "group-foo";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        // Consumer group with one static member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .buildCoordinatorMetadataImage())
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10))
            .build();

        assertThrows(GroupIdNotFoundException.class, () ->
            context.shareGroupHeartbeat(
                new ShareGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(Uuid.randomUuid().toString())
                    .setMemberEpoch(1)
                    .setSubscribedTopicNames(List.of("foo", "bar"))));
        verify(context.metrics, times(0)).record(SHARE_GROUP_REBALANCES_SENSOR_NAME);
    }

    @Test
    public void testShareGroupDescribeOnConsumerGroup() {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        int epoch = 10;
        String topicName = "topicName";
        ConsumerGroupMember.Builder memberBuilder = new ConsumerGroupMember.Builder(memberId)
            .setSubscribedTopicNames(List.of(topicName))
            .setServerAssignorName("assignorName");

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, epoch)
                .withMember(memberBuilder.build()))
            .build();

        List<ShareGroupDescribeResponseData.DescribedGroup> expected = List.of(
            new ShareGroupDescribeResponseData.DescribedGroup()
                .setGroupId(groupId)
                .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code())
                .setErrorMessage("Group " + groupId + " is not a share group.")
        );

        List<ShareGroupDescribeResponseData.DescribedGroup> actual = context.sendShareGroupDescribe(List.of(groupId));
        assertEquals(expected, actual);
    }

    @Test
    public void testConsumerGroupHeartbeatOnStreamsGroup() {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 1)
                .withMember(StreamsGroupMember.Builder.withDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .build())
                .withTargetAssignment(memberId, TasksTuple.EMPTY)
                .withTargetAssignmentEpoch(1))
            .build();

        assertThrows(GroupIdNotFoundException.class, () -> context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setMemberId(memberId)
                .setGroupId(groupId)
                .setMemberEpoch(0)
                .setServerAssignor("range")
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicNames(List.of("foo", "bar"))
                .setTopicPartitions(Collections.emptyList())));
    }

    @Test
    public void testShareGroupHeartbeatOnStreamsGroup() {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 1)
                .withMember(StreamsGroupMember.Builder.withDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .build())
                .withTargetAssignment(memberId, TasksTuple.EMPTY)
                .withTargetAssignmentEpoch(1))
            .build();

        assertThrows(GroupIdNotFoundException.class, () -> context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setMemberId(memberId)
                .setGroupId(groupId)
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of("foo", "bar"))));
        verify(context.metrics, times(0)).record(SHARE_GROUP_REBALANCES_SENSOR_NAME);
    }

    @Test
    public void testClassicGroupJoinOnStreamsGroup() throws Exception {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 1)
                .withMember(StreamsGroupMember.Builder.withDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .build())
                .withTargetAssignment(memberId, TasksTuple.EMPTY)
                .withTargetAssignmentEpoch(1))
            .build();

        JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withProtocolType("consumer")
            .withProtocols(new JoinGroupRequestProtocolCollection(0))
            .build();

        GroupMetadataManagerTestContext.JoinResult joinResult = context.sendClassicGroupJoin(request);
        assertTrue(joinResult.joinFuture.isDone());
        assertEquals(Errors.INCONSISTENT_GROUP_PROTOCOL.code(), joinResult.joinFuture.get().errorCode());
    }

    @Test
    public void testClassicGroupSyncOnStreamsGroup() throws Exception {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 1)
                .withMember(StreamsGroupMember.Builder.withDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .build())
                .withTargetAssignment(memberId, TasksTuple.EMPTY)
                .withTargetAssignmentEpoch(1))
            .build();

        SyncGroupRequestData request = new GroupMetadataManagerTestContext.SyncGroupRequestBuilder()
            .withGroupId(groupId)
            .withGenerationId(1)
            .withMemberId(memberId)
            .build();

        GroupMetadataManagerTestContext.SyncResult syncResult = context.sendClassicGroupSync(request);

        assertTrue(syncResult.records.isEmpty());
        assertTrue(syncResult.syncFuture.isDone());
        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), syncResult.syncFuture.get().errorCode());
    }

    @Test
    public void testClassicGroupLeaveOnStreamsGroup() throws Exception {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 1)
                .withMember(StreamsGroupMember.Builder.withDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .build())
                .withTargetAssignment(memberId, TasksTuple.EMPTY)
                .withTargetAssignmentEpoch(1))
            .build();

        assertThrows(UnknownMemberIdException.class, () -> context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId(groupId)
                .setMembers(List.of(
                    new MemberIdentity()
                        .setMemberId(memberId)))));
    }

    @Test
    public void testConsumerGroupDescribeOnStreamsGroup() {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 1)
                .withMember(StreamsGroupMember.Builder.withDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .build())
                .withTargetAssignment(memberId, TasksTuple.EMPTY)
                .withTargetAssignmentEpoch(1))
            .build();

        List<ConsumerGroupDescribeResponseData.DescribedGroup> expected = List.of(
            new ConsumerGroupDescribeResponseData.DescribedGroup()
                .setGroupId(groupId)
                .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code())
                .setErrorMessage("Group " + groupId + " is not a consumer group.")
        );

        List<ConsumerGroupDescribeResponseData.DescribedGroup> actual = context.sendConsumerGroupDescribe(List.of(groupId));
        assertEquals(expected, actual);
    }

    @Test
    public void testShareGroupDescribeOnStreamsGroup() {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(CoordinatorMetadataImage.EMPTY)
            .withStreamsGroup(new StreamsGroupBuilder(groupId, 1)
                .withMember(StreamsGroupMember.Builder.withDefaults(memberId)
                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
                    .setMemberEpoch(1)
                    .setPreviousMemberEpoch(0)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .build())
                .withTargetAssignment(memberId, TasksTuple.EMPTY)
                .withTargetAssignmentEpoch(1))
            .build();

        List<ShareGroupDescribeResponseData.DescribedGroup> expected = List.of(
            new ShareGroupDescribeResponseData.DescribedGroup()
                .setGroupId(groupId)
                .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code())
                .setErrorMessage("Group " + groupId + " is not a share group.")
        );

        List<ShareGroupDescribeResponseData.DescribedGroup> actual = context.sendShareGroupDescribe(List.of(groupId));
        assertEquals(expected, actual);
    }

    @Test
    public void testStreamsGroupHeartbeatOnConsumerGroup() {
        String groupId = "group-foo";
        // Use a static member id as it makes the test easier.
        String memberId1 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        // Consumer group with one static member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .buildCoordinatorMetadataImage())
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setInstanceId(memberId1)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10))
            .build();

        assertThrows(GroupIdNotFoundException.class, () ->
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(Uuid.randomUuid().toString())
                    .setMemberEpoch(1)));
    }

    @Test
    public void testStreamsGroupDescribeOnConsumerGroup() {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        int epoch = 10;
        String topicName = "topicName";
        ConsumerGroupMember.Builder memberBuilder = new ConsumerGroupMember.Builder(memberId)
            .setSubscribedTopicNames(List.of(topicName))
            .setServerAssignorName("assignorName");

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, epoch)
                .withMember(memberBuilder.build()))
            .build();

        List<StreamsGroupDescribeResponseData.DescribedGroup> expected = List.of(
            new StreamsGroupDescribeResponseData.DescribedGroup()
                .setGroupId(groupId)
                .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code())
                .setErrorMessage("Group " + groupId + " is not a streams group.")
        );

        List<StreamsGroupDescribeResponseData.DescribedGroup> actual = context.sendStreamsGroupDescribe(List.of(groupId));
        assertEquals(expected, actual);
    }


    @Test
    public void testStreamsGroupHeartbeatOnShareGroup() {
        String groupId = "group-foo";
        String memberId1 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        // Share group with one member.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 6)
                .buildCoordinatorMetadataImage())
            .withShareGroup(new ShareGroupBuilder(groupId, 10)
                .withMember(new ShareGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(9)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicNames(List.of("foo", "bar"))
                    .setAssignedPartitions(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10))
            .build();

        assertThrows(GroupIdNotFoundException.class, () ->
            context.streamsGroupHeartbeat(
                new StreamsGroupHeartbeatRequestData()
                    .setGroupId(groupId)
                    .setMemberId(Uuid.randomUuid().toString())
                    .setMemberEpoch(1)));
    }

    @Test
    public void testStreamsGroupDescribeOnShareGroup() {
        String groupId = "group-foo";
        String memberId = Uuid.randomUuid().toString();

        int epoch = 10;
        String topicName = "topicName";
        ShareGroupMember.Builder memberBuilder = new ShareGroupMember.Builder(memberId)
            .setSubscribedTopicNames(List.of(topicName));

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroup(new ShareGroupBuilder(groupId, epoch)
                .withMember(memberBuilder.build()))
            .build();

        List<StreamsGroupDescribeResponseData.DescribedGroup> expected = List.of(
            new StreamsGroupDescribeResponseData.DescribedGroup()
                .setGroupId(groupId)
                .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code())
                .setErrorMessage("Group " + groupId + " is not a streams group.")
        );

        List<StreamsGroupDescribeResponseData.DescribedGroup> actual = context.sendStreamsGroupDescribe(List.of(groupId));
        assertEquals(expected, actual);
    }

    @Test
    public void testReplayConsumerGroupRegularExpression() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        ResolvedRegularExpression resolvedRegularExpression = new ResolvedRegularExpression(
            Set.of("abc", "abcd"),
            10L,
            12345L
        );

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
            "foo",
            "abc*",
            resolvedRegularExpression
        ));

        assertEquals(
            Optional.of(resolvedRegularExpression),
            context.groupMetadataManager.consumerGroup("foo").resolvedRegularExpression("abc*")
        );

        assertEquals(
            Set.of("foo"),
            context.groupMetadataManager.groupsSubscribedToTopic("abc")
        );
    }

    @Test
    public void testReplayConsumerGroupRegularExpressionTombstone() {
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .build();

        // The group may not exist at all. Replaying the ConsumerGroupRegularExpression tombstone
        // should be a no-op.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone("foo", "abc*"));
        assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.consumerGroup("foo"));

        // Otherwise, it should remove the regular expression.
        ResolvedRegularExpression resolvedRegularExpression = new ResolvedRegularExpression(
            Set.of("abc", "abcd"),
            10L,
            12345L
        );

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
            "foo",
            "abc*",
            resolvedRegularExpression
        ));

        assertEquals(
            Set.of("foo"),
            context.groupMetadataManager.groupsSubscribedToTopic("abc")
        );

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(
            "foo",
            "abc*"
        ));

        assertEquals(
            Optional.empty(),
            context.groupMetadataManager.consumerGroup("foo").resolvedRegularExpression("abc*")
        );

        assertEquals(
            Set.of(),
            context.groupMetadataManager.groupsSubscribedToTopic("abc")
        );
    }

    @Test
    public void testConsumerGroupMemberPicksUpExistingResolvedRegularExpression() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        ConsumerGroupPartitionAssignor assignor = mock(ConsumerGroupPartitionAssignor.class);
        when(assignor.name()).thenReturn("range");
        when(assignor.assign(any(), any())).thenAnswer(answer -> {
            GroupSpec spec = answer.getArgument(0);

            List.of(memberId1, memberId2).forEach(memberId ->
                assertEquals(
                    Set.of(fooTopicId),
                    spec.memberSubscription(memberId).subscribedTopicIds(),
                    String.format("Member %s has unexpected subscribed topic ids", memberId)
                )
            );

            return new GroupAssignment(Map.of(
                memberId1, new MemberAssignmentImpl(mkAssignment(
                    mkTopicAssignment(fooTopicId, 0)
                )),
                memberId2, new MemberAssignmentImpl(mkAssignment(
                    mkTopicAssignment(fooTopicId, 1)
                ))
            ));
        });

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(fooTopicId, fooTopicName, 2)
                .buildCoordinatorMetadataImage())
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setSubscribedTopicRegex("foo*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1)), 10))
                    .build())
                .withResolvedRegularExpression("foo*", new ResolvedRegularExpression(
                    Set.of(fooTopicName),
                    100L,
                    12345L))
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1)))
                .withAssignmentEpoch(10))
            .build();

        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(10000)
                .setSubscribedTopicRegex("foo*")
                .setTopicPartitions(List.of()));

        assertEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()),
            result.response()
        );
    }

    @Test
    public void testConsumerGroupMemberJoinsWithNewRegex() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage(12345L);
        long groupMetadataHash = computeGroupHash(Map.of(
            fooTopicName, computeTopicHash(fooTopicName, metadataImage)
        ));

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withMetadataHash(groupMetadataHash))
            .build();

        // Member 2 joins the consumer group with a new regular expression.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()),
            result.response()
        );

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*")
            .setServerAssignorName("range")
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember2),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
        );

        assertRecordsEquals(expectedRecords, result.records());

        // Execute pending tasks.
        List<MockCoordinatorExecutor.ExecutorResult<CoordinatorRecord>> tasks = context.processTasks();
        assertEquals(
            List.of(
                new MockCoordinatorExecutor.ExecutorResult<>(
                    groupId + "-regex",
                    new CoordinatorResult<>(List.of(
                        // The resolution of the new regex is persisted.
                        GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
                            groupId,
                            "foo*",
                            new ResolvedRegularExpression(
                                Set.of("foo"),
                                12345L,
                                context.time.milliseconds()
                            )
                        ),
                        // The group epoch is bumped.
                        GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, groupMetadataHash)
                    ))
                )
            ),
            tasks
        );
    }

    @Test
    public void testConsumerGroupMemberJoinsWithUpdatedRegex() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage(12345L);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("foo*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignmentEpoch(10))
            .build();

        // Member 1 updates its new regular expression.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result1 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(10)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo*|bar*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of())
                ),
            result1.response()
        );

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*|bar*")
            .setServerAssignorName("range")
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            // The member subscription is updated.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1),
            // The previous regular expression is deleted.
            GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "foo*"),
            // The member assignment is updated.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1)
        );

        assertRecordsEquals(expectedRecords, result1.records());

        // Execute pending tasks.
        List<MockCoordinatorExecutor.ExecutorResult<CoordinatorRecord>> tasks = context.processTasks();
        assertEquals(1, tasks.size());

        MockCoordinatorExecutor.ExecutorResult<CoordinatorRecord> task = tasks.get(0);
        assertEquals(groupId + "-regex", task.key());
        assertRecordsEquals(
            List.of(
                // The resolution of the new regex is persisted.
                GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
                    groupId,
                    "foo*|bar*",
                    new ResolvedRegularExpression(
                        Set.of("foo", "bar"),
                        12345L,
                        context.time.milliseconds()
                    )
                ),
                // The group epoch is bumped.
                GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                )))
            ),
            task.result().records()
        );

        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            ))
        )));

        // Member heartbeats again with the same regex.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result2 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(10)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo*|bar*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5)),
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(barTopicId)
                            .setPartitions(List.of(0, 1, 2))))),
            result2.response()
        );

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*|bar*")
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)), 11))
            .build();

        expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            )),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
        );

        assertRecordsEquals(expectedRecords, result2.records());
    }

    @Test
    public void testConsumerGroupMemberJoinsWithRegexAndUpdatesItBeforeResolutionCompleted() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage(12345L);

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Member 1 joins.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(1)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()),
            result.response()
        );

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(1)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*")
            .setServerAssignorName("range")
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            // The member subscription is created.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1),

            // The group is initialized at group epoch 1. Since the group epoch is not bumped until
            // regex resolution has completed, no consumer group metadata record is created.
            // Similarly, the target assignment is initialized at epoch 1 with an empty assignment
            // and not updated until regex resolution has completed, so no target assignment records
            // are created.

            // The member current state is created.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1)
        );

        assertRecordsEquals(expectedRecords, result.records());

        // The task is scheduled.
        assertTrue(context.executor.isScheduled(groupId + "-regex"));

        // The member updates its regex before the resolution of the previous one completes.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(1)
                .setSubscribedTopicRegex("foo*|bar*"));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(1)
                .setHeartbeatIntervalMs(5000),
            result.response()
        );

        expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(1)
            .setPreviousMemberEpoch(1)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*|bar*")
            .setServerAssignorName("range")
            .build();

        expectedRecords = List.of(
            // The member subscription is updated.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1),
            // The previous regex is deleted.
            GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "foo*"),
            // The previous member epoch is updated.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1)
        );

        assertRecordsEquals(expectedRecords, result.records());

        // The task is still scheduled.
        assertTrue(context.executor.isScheduled(groupId + "-regex"));
        assertEquals(1, context.executor.size());

        // Execute the pending tasks.
        List<MockCoordinatorExecutor.ExecutorResult<CoordinatorRecord>> tasks = context.processTasks();
        assertEquals(1, tasks.size());

        // The pending task was a no-op.
        MockCoordinatorExecutor.ExecutorResult<CoordinatorRecord> task = tasks.get(0);
        assertEquals(groupId + "-regex", task.key());
        assertRecordsEquals(List.of(), task.result().records());

        // The member heartbeats again. It triggers a new resolution.
        result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(1)
                .setSubscribedTopicRegex("foo*|bar*"));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(1)
                .setHeartbeatIntervalMs(5000),
            result.response()
        );

        assertTrue(context.executor.isScheduled(groupId + "-regex"));
        assertEquals(1, context.executor.size());

        // Execute pending tasks.
        tasks = context.processTasks();
        assertEquals(1, tasks.size());

        task = tasks.get(0);
        assertEquals(groupId + "-regex", task.key());
        assertRecordsEquals(
            List.of(
                // The resolution of the new regex is persisted.
                GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
                    groupId,
                    "foo*|bar*",
                    new ResolvedRegularExpression(
                        Set.of("foo", "bar"),
                        12345L,
                        context.time.milliseconds()
                    )
                ),
                // The group epoch is bumped.
                GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 2, computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage),
                    barTopicName, computeTopicHash(barTopicName, metadataImage)
                )))
            ),
            task.result().records()
        );
    }

    @Test
    public void testConsumerGroupMemberJoinRefreshesExpiredRegexes() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";
        Uuid foooTopicId = Uuid.randomUuid();
        String foooTopicName = "fooo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));

        MetadataImage image = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .build(1L);
        long fooTopicHash = computeTopicHash(fooTopicName, new KRaftCoordinatorMetadataImage(image));
        long barTopicHash = computeTopicHash(barTopicName, new KRaftCoordinatorMetadataImage(image));

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(new KRaftCoordinatorMetadataImage(image))
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("foo*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("bar*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(barTopicId, 0, 1, 2)), 10))
                    .build())
                .withResolvedRegularExpression("foo*", new ResolvedRegularExpression(
                    Set.of(fooTopicName), 0L, 0L))
                .withResolvedRegularExpression("bar*", new ResolvedRegularExpression(
                    Set.of(barTopicName), 0L, 0L))
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(barTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash
                ))))
            .build();

        // Update metadata image.
        MetadataImage newImage = new MetadataImageBuilder(image)
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .addTopic(foooTopicId, foooTopicName, 1)
            .build(2L);

        context.groupMetadataManager.onMetadataUpdate(
            new KRaftCoordinatorMetadataDelta(new MetadataDelta.Builder()
                .setImage(newImage)
                .build()), new KRaftCoordinatorMetadataImage(newImage)
        );

        // A member heartbeats.
        context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(10));

        // The task is NOT scheduled.
        assertFalse(context.executor.isScheduled(groupId + "-regex"));

        // Advance past the batching interval.
        context.sleep(11000L);

        // A member heartbeats.
        context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(10));

        // The task is scheduled.
        assertTrue(context.executor.isScheduled(groupId + "-regex"));

        // Execute the pending tasks.
        List<MockCoordinatorExecutor.ExecutorResult<CoordinatorRecord>> tasks = context.processTasks();
        assertEquals(1, tasks.size());

        // Execute pending tasks.
        MockCoordinatorExecutor.ExecutorResult<CoordinatorRecord> task = tasks.get(0);
        assertEquals(groupId + "-regex", task.key());

        assertUnorderedRecordsEquals(
            List.of(
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
                        groupId,
                        "foo*",
                        new ResolvedRegularExpression(
                            Set.of(fooTopicName, foooTopicName),
                            2L,
                            context.time.milliseconds()
                        )
                    ),
                    GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
                        groupId,
                        "bar*",
                        new ResolvedRegularExpression(
                            Set.of(barTopicName),
                            2L,
                            context.time.milliseconds()
                        )
                    )
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash,
                    foooTopicName, computeTopicHash(foooTopicName, new KRaftCoordinatorMetadataImage(newImage))
                ))))
            ),
            task.result().records()
        );
    }

    @Test
    public void testConsumerGroupMemberClearsRegex() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage(12345L);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("foo*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignmentEpoch(10))
            .build();

        // Member 1 updates its new regular expression.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(10)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of())
                ),
            result.response()
        );

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("")
            .setServerAssignorName("range")
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1),
            // previous expression is deleted
            GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "foo*"),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, 0),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, Map.of()),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1)
        );

        assertRecordsEquals(expectedRecords, result.records());
    }

    @Test
    public void testConsumerMemberWithRegexReplacedByClassicMemberWithSameSubscription() {
        String groupId = "fooup";
        String instanceId = "instance-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 1)
            .buildCoordinatorMetadataImage(12345L);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));

        // Member 1 is a static member with both regex and topic name subscription
        // Member 2 uses topic name subscription.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setInstanceId(instanceId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("bar*")
                    .setSubscribedTopicNames(List.of(fooTopicName))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2),
                        mkTopicAssignment(barTopicId, 0)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of(fooTopicName))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2),
                    mkTopicAssignment(barTopicId, 0)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withResolvedRegularExpression("bar*", new ResolvedRegularExpression(
                    Set.of(barTopicName), 0L, 0L)))
            .build();
        ConsumerGroup group = context.groupMetadataManager.consumerGroup(groupId);
        group.setMetadataRefreshDeadline(Long.MAX_VALUE, 10);

        // Member 1 is replaced by a classic member with the same instance id.
        JoinGroupRequestProtocolCollection joinProtocols = new JoinGroupRequestProtocolCollection();
        joinProtocols.add(new JoinGroupRequestData.JoinGroupRequestProtocol()
            .setName("range")
            .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                List.of(fooTopicName)
            ))))
        );
        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId(instanceId)
            .withRebalanceTimeoutMs(5000)
            .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
            .withProtocols(joinProtocols)
            .build();
        GroupMetadataManagerTestContext.JoinResult result = context.sendClassicGroupJoin(joinRequest);

        ConsumerGroupMember newMember1 = group.staticMember(instanceId);

        ConsumerGroupMember expectedCopiedMember = new ConsumerGroupMember.Builder(newMember1.memberId())
            .setState(MemberState.STABLE)
            .setInstanceId(instanceId)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("bar*") // Still uses regex subscription.
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2),
                mkTopicAssignment(barTopicId, 0)), 10))
            .build();

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(newMember1.memberId())
            .setState(MemberState.STABLE)
            .setInstanceId(instanceId)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("") // empty regex subscription
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setServerAssignorName("range")
            .setAssignedPartitions(Map.of()) // empty assignment
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(500)
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(joinRequest.protocols()))
            )
            .build();

        List<List<CoordinatorRecord>> expectedRecords = List.of(
            // The previous member is deleted.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1)),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1)),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1)),
            // The previous member is replaced by the new one.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedCopiedMember)),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, expectedCopiedMember.memberId(), toAssignmentWithoutEpochs(expectedCopiedMember.assignedPartitions()))),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedCopiedMember)),
            // The member subscription is updated.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1)),
            // The regex is tombstoned.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "bar*")),
            // The group epoch is bumped.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage)
            )))),
            // The target assignment is updated.
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, expectedMember1.memberId(), Map.of()),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, Map.of())
            ),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds())),
            // The member assignment is updated.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1))
        );

        assertUnorderedRecordsEquals(
            expectedRecords,
            result.records
        );
    }

    @Test
    public void testConsumerMemberWithRegexReplacedByClassicMemberWithChangedSubscription() {
        String groupId = "fooup";
        String instanceId = "instance-id";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage(12345L);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));

        // Member 1 is a static member with regex subscription and
        // Member 2 uses topic name subscription.
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setInstanceId(instanceId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("foo*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of(fooTopicName))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withAssignmentEpoch(10)
                .withResolvedRegularExpression("foo*", new ResolvedRegularExpression(
                    Set.of(fooTopicName), 0L, 0L)))
            .build();
        ConsumerGroup group = context.groupMetadataManager.consumerGroup(groupId);
        group.setMetadataRefreshDeadline(Long.MAX_VALUE, 10);

        // Member 1 is replaced by a classic member with the same instance id.
        JoinGroupRequestProtocolCollection joinProtocols = new JoinGroupRequestProtocolCollection();
        joinProtocols.add(new JoinGroupRequestData.JoinGroupRequestProtocol()
            .setName("range")
            .setMetadata(Utils.toArray(ConsumerProtocol.serializeSubscription(new ConsumerPartitionAssignor.Subscription(
                List.of()
            ))))
        );
        JoinGroupRequestData joinRequest = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
            .withGroupId(groupId)
            .withMemberId(UNKNOWN_MEMBER_ID)
            .withGroupInstanceId(instanceId)
            .withRebalanceTimeoutMs(5000)
            .withProtocolType(ConsumerProtocol.PROTOCOL_TYPE)
            .withProtocols(joinProtocols)
            .build();
        GroupMetadataManagerTestContext.JoinResult result = context.sendClassicGroupJoin(joinRequest);

        ConsumerGroupMember newMember1 = group.staticMember(instanceId);

        ConsumerGroupMember expectedCopiedMember = new ConsumerGroupMember.Builder(newMember1.memberId())
            .setState(MemberState.STABLE)
            .setInstanceId(instanceId)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*") // Still uses regex subscription.
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
            .build();

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(newMember1.memberId())
            .setState(MemberState.STABLE)
            .setInstanceId(instanceId)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("") // empty regex subscription
            .setServerAssignorName("range")
            .setAssignedPartitions(Map.of()) // empty assignment
            .setClassicMemberMetadata(
                new ConsumerGroupMemberMetadataValue.ClassicMemberMetadata()
                    .setSessionTimeoutMs(500)
                    .setSupportedProtocols(ConsumerGroupMember.classicProtocolListFromJoinRequestProtocolCollection(joinRequest.protocols()))
            )
            .build();

        List<List<CoordinatorRecord>> expectedRecords = List.of(
            // The previous member is deleted.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1)),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1)),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1)),
            // The previous member is replaced by the new one.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedCopiedMember)),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, expectedCopiedMember.memberId(), toAssignmentWithoutEpochs(expectedCopiedMember.assignedPartitions()))),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedCopiedMember)),
            // The member subscription is updated.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1)),
            // The regex is tombstoned.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "foo*")),
            // The group epoch is bumped.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                fooTopicName, computeTopicHash(fooTopicName, metadataImage)
            )))),
            // The target assignment is updated.
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, expectedMember1.memberId(), Map.of()),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, Map.of())
            ),
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds())),
            // The member assignment is updated.
            List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1))
        );

        assertUnorderedRecordsEquals(
            expectedRecords,
            result.records
        );
    }

    @Test
    public void testConsumerGroupMemberJoinsWithRegexWithTopicAuthorizationFailure() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        Uuid barTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        String barTopicName = "bar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage(12345L);
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        Authorizer authorizer = mock(Authorizer.class);
        Plugin<Authorizer> authorizerPlugin = Plugin.wrapInstance(authorizer, null, "authorizer.class.name");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withAuthorizerPlugin(authorizerPlugin)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("foo*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withResolvedRegularExpression("foo*", new ResolvedRegularExpression(
                    Set.of(fooTopicName), 0L, 0L))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(fooTopicName, fooTopicHash))))
            .build();

        // sleep for more than REGEX_BATCH_REFRESH_MIN_INTERVAL_MS
        context.time.sleep(10001L);

        Map<String, AuthorizationResult> acls = new HashMap<>();
        acls.put(fooTopicName, AuthorizationResult.ALLOWED);
        acls.put(barTopicName, AuthorizationResult.DENIED);
        when(authorizer.authorize(any(), any())).thenAnswer(invocation -> {
            List<Action> actions = invocation.getArgument(1);
            return actions.stream()
                .map(action -> acls.getOrDefault(action.resourcePattern().name(), AuthorizationResult.DENIED))
                .collect(Collectors.toList());
        });

        // Member 2 heartbeats with a different regular expression.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result1 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo*|bar*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()),
            ApiKeys.CONSUMER_GROUP_HEARTBEAT.latestVersion()
        );

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of())),
            result1.response()
        );

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*|bar*")
            .setServerAssignorName("range")
            .build();

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember2),
                GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "foo*"),
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
            ),
            result1.records()
        );

        // Execute pending tasks.
        assertEquals(
            List.of(
                new MockCoordinatorExecutor.ExecutorResult<>(
                    groupId + "-regex",
                    new CoordinatorResult<>(List.of(
                        // The resolution of the new regex is persisted.
                        GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
                            groupId,
                            "foo*|bar*",
                            new ResolvedRegularExpression(
                                Set.of("foo"),
                                12345L,
                                context.time.milliseconds()
                            )
                        ),
                        GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                            fooTopicName, fooTopicHash
                        )))
                    ))
                )
            ),
            context.processTasks()
        );

        // sleep for more than REGEX_BATCH_REFRESH_MIN_INTERVAL_MS
        context.time.sleep(10001L);

        // Access to the bar topic is granted.
        acls.put(barTopicName, AuthorizationResult.ALLOWED);
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5)
            ))
        )));

        // Member 2 heartbeats again with a new regex.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result2 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo|bar*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()),
            ApiKeys.CONSUMER_GROUP_HEARTBEAT.latestVersion()
        );

        expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo|bar*")
            .setServerAssignorName("range")
            .setAssignedPartitions(Map.of())
            .build();

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of())),
            result2.response()
        );

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember2),
                GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "foo*|bar*"),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
            ),
            result2.records()
        );

        // A regex refresh is triggered and the bar topic is included.
        assertRecordsEquals(
            List.of(
                // The resolution of the new regex is persisted.
                GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
                    groupId,
                    "foo|bar*",
                    new ResolvedRegularExpression(
                        Set.of("foo", "bar"),
                        12345L,
                        context.time.milliseconds()
                    )
                ),
                GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 12, computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash
                )))
            ),
            context.processTasks().get(0).result().records()
        );
    }

    @Test
    public void testConsumerGroupMemberJoinsRefreshTopicAuthorization() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        Uuid barTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        String barTopicName = "bar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage(12345L);
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        Authorizer authorizer = mock(Authorizer.class);
        Plugin<Authorizer> authorizerPlugin = Plugin.wrapInstance(authorizer, null, "authorizer.class.name");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_REGEX_REFRESH_INTERVAL_MS_CONFIG, 60000)
            .withMetadataImage(metadataImage)
            .withAuthorizerPlugin(authorizerPlugin)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicNames(List.of("foo"))
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("foo*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 3, 4, 5)))
                .withResolvedRegularExpression("foo*", new ResolvedRegularExpression(
                    Set.of(fooTopicName), 0L, 0L))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(fooTopicName, fooTopicHash))))
            .build();

        // sleep for more than REGEX_BATCH_REFRESH_MIN_INTERVAL_MS
        context.time.sleep(10001L);

        Map<String, AuthorizationResult> acls = new HashMap<>();
        acls.put(fooTopicName, AuthorizationResult.ALLOWED);
        acls.put(barTopicName, AuthorizationResult.DENIED);
        when(authorizer.authorize(any(), any())).thenAnswer(invocation -> {
            List<Action> actions = invocation.getArgument(1);
            return actions.stream()
                .map(action -> acls.getOrDefault(action.resourcePattern().name(), AuthorizationResult.DENIED))
                .collect(Collectors.toList());
        });

        // Member 2 heartbeats with a different regular expression.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result1 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo*|bar*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()),
            ApiKeys.CONSUMER_GROUP_HEARTBEAT.latestVersion()
        );

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of())),
            result1.response()
        );

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*|bar*")
            .setServerAssignorName("range")
            .build();

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember2),
                GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "foo*"),
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
            ),
            result1.records()
        );

        // Execute pending tasks.
        assertEquals(
            List.of(
                new MockCoordinatorExecutor.ExecutorResult<>(
                    groupId + "-regex",
                    new CoordinatorResult<>(List.of(
                        // The resolution of the new regex is persisted.
                        GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
                            groupId,
                            "foo*|bar*",
                            new ResolvedRegularExpression(
                                Set.of("foo"),
                                12345L,
                                context.time.milliseconds()
                            )
                        ),
                        GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                            fooTopicName, fooTopicHash
                        )))
                    ))
                )
            ),
            context.processTasks()
        );

        // sleep for more than REGEX_REFRESH_INTERVAL_MS
        context.time.sleep(60001L);

        // Access to the bar topic is granted.
        acls.put(barTopicName, AuthorizationResult.ALLOWED);
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5)
            ))
        )));

        // Member 2 heartbeats again with the same regex.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result2 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo*|bar*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()),
            ApiKeys.CONSUMER_GROUP_HEARTBEAT.latestVersion()
        );

        expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*|bar*")
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5)), 11))
            .build();

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(3, 4, 5))))),
            result2.response()
        );

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
            ),
            result2.records()
        );

        // A regex refresh is triggered and the bar topic is included.
        assertRecordsEquals(
            List.of(
                // The resolution of the new regex is persisted.
                GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
                    groupId,
                    "foo*|bar*",
                    new ResolvedRegularExpression(
                        Set.of("foo", "bar"),
                        12345L,
                        context.time.milliseconds()
                    )
                ),
                GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 12, computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash
                )))
            ),
            context.processTasks().get(0).result().records()
        );
    }

    @Test
    public void testStaticConsumerGroupMemberJoinsWithUpdatedRegex() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String instanceId = "instance-id";

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage(12345L);

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setInstanceId(instanceId)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("foo*|bar*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                        mkTopicAssignment(barTopicId, 0, 1, 2)), 10))
                    .build())
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                    mkTopicAssignment(barTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10))
            .build();

        // Static member temporarily leaves the group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result1 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setInstanceId(instanceId)
                .setMemberId(memberId1)
                .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH)
        );

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH),
            result1.response()
        );

        // Static member joins the group with an updated regular expression.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result2 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setInstanceId(instanceId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        // The returned assignment does not contain topics not in the current regular expression.
        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of())
                ),
            result2.response()
        );

        ConsumerGroupMember expectedCopiedMember = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setInstanceId(instanceId)
            .setMemberEpoch(0)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*|bar*")
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)), 0))
            .build();

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setInstanceId(instanceId)
            .setMemberEpoch(10)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*")
            .setServerAssignorName("range")
            .build();

        List<CoordinatorRecord> expectedRecords = List.of(
            // The previous member is deleted.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
            // The previous member is replaced by the new one.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedCopiedMember),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5),
                mkTopicAssignment(barTopicId, 0, 1, 2)
            )),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedCopiedMember),
            // The member subscription is updated.
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1),
            // The previous regular expression is deleted.
            GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "foo*|bar*"),
            // The member assignment is updated.
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1)
        );

        assertRecordsEquals(expectedRecords, result2.records());

        // Execute pending tasks.
        List<MockCoordinatorExecutor.ExecutorResult<CoordinatorRecord>> tasks = context.processTasks();
        assertEquals(1, tasks.size());

        MockCoordinatorExecutor.ExecutorResult<CoordinatorRecord> task = tasks.get(0);
        assertEquals(groupId + "-regex", task.key());
        assertRecordsEquals(
            List.of(
                // The resolution of the new regex is persisted.
                GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionRecord(
                    groupId,
                    "foo*",
                    new ResolvedRegularExpression(
                        Set.of("foo"),
                        12345L,
                        context.time.milliseconds()
                    )
                ),
                // The group epoch is bumped.
                GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage)
                )))
            ),
            task.result().records()
        );

        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            ))
        )));

        // Member heartbeats again with the same regex.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result3 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setInstanceId(instanceId)
                .setMemberId(memberId2)
                .setMemberEpoch(10)
                .setRebalanceTimeoutMs(5000)
                .setSubscribedTopicRegex("foo*")
                .setServerAssignor("range")
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(11)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5))))),
            result3.response()
        );

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setInstanceId(instanceId)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicRegex("foo*|bar*")
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 11))
            .build();

        expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 11, context.time.milliseconds()),
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
        );

        assertRecordsEquals(expectedRecords, result3.records());
    }

    @Test
    public void testResolvedRegularExpressionsRemovedWhenMembersLeaveOrFenced() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage(1L);
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("foo*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("bar*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(barTopicId, 0, 1, 2)), 10))
                    .build())
                .withResolvedRegularExpression("foo*", new ResolvedRegularExpression(
                    Set.of(fooTopicName), 0L, 0L))
                .withResolvedRegularExpression("bar*", new ResolvedRegularExpression(
                    Set.of(barTopicName), 0L, 0L))
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(barTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash
                ))))
            .build();

        // Setup the timers.
        context.onLoaded();

        // Member 1 leaves the group.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(-1));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(-1),
            result.response()
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
            GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
            GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
            GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "foo*"),
            GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                barTopicName, barTopicHash
            )))
        );

        assertRecordsEquals(expectedRecords, result.records());

        // Member 2 is fenced due to reaching the session timeout.
        context.assertSessionTimeout(groupId, memberId2, 45000);
        List<ExpiredTimeout<CoordinatorRecord>> timeouts = context.sleep(45000 + 1);

        // Verify the expired timeout.
        assertEquals(
            List.of(new ExpiredTimeout<>(
                groupSessionTimeoutKey(groupId, memberId2),
                new CoordinatorResult<>(
                    List.of(
                        GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2),
                        GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2),
                        GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2),
                        GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "bar*"),
                        GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 12, 0)
                    )
                )
            )),
            timeouts
        );
    }

    @Test
    public void testResolvedRegularExpressionsRemovedWhenConsumerMembersRemovedByAdminApi() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();
        String memberId3 = Uuid.randomUuid().toString();
        String memberId4 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Uuid barTopicId = Uuid.randomUuid();
        String barTopicName = "bar";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .addTopic(barTopicId, barTopicName, 3)
            .buildCoordinatorMetadataImage(1L);
        long fooTopicHash = computeTopicHash(fooTopicName, metadataImage);
        long barTopicHash = computeTopicHash(barTopicName, metadataImage);

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .withConsumerGroup(new ConsumerGroupBuilder(groupId, 10)
                .withMember(new ConsumerGroupMember.Builder(memberId1)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setInstanceId(memberId1)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("foo*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId2)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setInstanceId(memberId2)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("foo*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId3)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setInstanceId(memberId3)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("bar*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(barTopicId, 0, 1)), 10))
                    .build())
                .withMember(new ConsumerGroupMember.Builder(memberId4)
                    .setState(MemberState.STABLE)
                    .setMemberEpoch(10)
                    .setPreviousMemberEpoch(10)
                    .setInstanceId(memberId4)
                    .setClientId(DEFAULT_CLIENT_ID)
                    .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
                    .setRebalanceTimeoutMs(5000)
                    .setSubscribedTopicRegex("bar*")
                    .setServerAssignorName("range")
                    .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                        mkTopicAssignment(barTopicId, 2)), 10))
                    .build())
                .withResolvedRegularExpression("foo*", new ResolvedRegularExpression(
                    Set.of(fooTopicName), 0L, 0L))
                .withResolvedRegularExpression("bar*", new ResolvedRegularExpression(
                    Set.of(barTopicName), 0L, 0L))
                .withAssignment(memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
                .withAssignment(memberId2, mkAssignment(
                    mkTopicAssignment(barTopicId, 0, 1, 2)))
                .withAssignmentEpoch(10)
                .withMetadataHash(computeGroupHash(Map.of(
                    fooTopicName, fooTopicHash,
                    barTopicName, barTopicHash
                ))))
            .build();

        // Remove members.
        CoordinatorResult<LeaveGroupResponseData, CoordinatorRecord> result = context.sendClassicGroupLeave(
            new LeaveGroupRequestData()
                .setGroupId(groupId)
                .setMembers(List.of(
                    new MemberIdentity()
                        .setMemberId(memberId1)
                        .setGroupInstanceId(null),
                    new MemberIdentity()
                        .setMemberId(memberId2)
                        .setGroupInstanceId(memberId2),
                    new MemberIdentity()
                        .setMemberId(UNKNOWN_MEMBER_ID)
                        .setGroupInstanceId(memberId3)
                ))
        );

        assertEquals(
            new LeaveGroupResponseData()
                .setMembers(List.of(
                    new LeaveGroupResponseData.MemberResponse()
                        .setMemberId(memberId1)
                        .setGroupInstanceId(null),
                    new LeaveGroupResponseData.MemberResponse()
                        .setMemberId(memberId2)
                        .setGroupInstanceId(memberId2),
                    new LeaveGroupResponseData.MemberResponse()
                        .setMemberId(UNKNOWN_MEMBER_ID)
                        .setGroupInstanceId(memberId3)
                )),
            result.response()
        );

        assertUnorderedRecordsEquals(
            List.of(
                List.of(
                    // Remove member 1.
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId1),
                    // Remove member 2.
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId2),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId2),
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId2),
                    // Remove member 3.
                    GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentTombstoneRecord(groupId, memberId3),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentTombstoneRecord(groupId, memberId3),
                    GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionTombstoneRecord(groupId, memberId3)
                ),
                // Remove regex.
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupRegularExpressionTombstone(groupId, "foo*")),
                // Bumped epoch.
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 11, computeGroupHash(Map.of(
                    barTopicName, barTopicHash
                ))))
            ),
            result.records()
        );
    }

    @Test
    public void testShareGroupDeleteRequestNoDeletingTopics() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .build();

        Uuid t1Uuid = Uuid.randomUuid();
        Uuid t2Uuid = Uuid.randomUuid();
        String t1Name = "t1";
        String t2Name = "t2";

        String groupId = "share-group";
        ShareGroup shareGroup = mock(ShareGroup.class);
        when(shareGroup.groupId()).thenReturn(groupId);
        when(shareGroup.isEmpty()).thenReturn(false);

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(t1Uuid, t1Name, 2)
            .addTopic(t2Uuid, t2Name, 2)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(image.emptyDelta(), image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        context.replay(
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(t1Uuid, new InitMapValue(t1Name, Set.of(0, 1), 1)),
                Map.of(t2Uuid, new InitMapValue(t2Name, Set.of(0, 1), 1)),
                Map.of()
            )
        );

        context.commit();

        Map<Uuid, Set<Integer>> expectedTopicPartitionMap = Map.of(
            t1Uuid, Set.of(0, 1),
            t2Uuid, Set.of(0, 1)
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(),
                Map.of(t1Uuid, t1Name, t2Uuid, t2Name)
            )
        );

        List<CoordinatorRecord> records = new ArrayList<>();
        Optional<DeleteShareGroupStateParameters> params = context.groupMetadataManager.shareGroupBuildPartitionDeleteRequest(groupId, records);
        verifyShareGroupDeleteRequest(
            params,
            expectedTopicPartitionMap,
            groupId,
            true
        );
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testShareGroupDeleteRequestWithAlreadyDeletingTopics() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .build();

        Uuid t1Uuid = Uuid.randomUuid();
        Uuid t2Uuid = Uuid.randomUuid();
        Uuid t3Uuid = Uuid.randomUuid();
        String t1Name = "t1";
        String t2Name = "t2";
        String t3Name = "t3";

        String groupId = "share-group";
        ShareGroup shareGroup = mock(ShareGroup.class);
        when(shareGroup.groupId()).thenReturn(groupId);
        when(shareGroup.isEmpty()).thenReturn(false);

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(t1Uuid, t1Name, 2)
            .addTopic(t2Uuid, t2Name, 2)
            .addTopic(t3Uuid, t3Name, 2)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(image.emptyDelta(), image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        context.replay(
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(t1Uuid, new InitMapValue(t1Name, Set.of(0, 1), 1)),
                Map.of(t2Uuid, new InitMapValue(t2Name, Set.of(0, 1), 1)),
                Map.of(t3Uuid, t3Name)
            )
        );

        context.commit();

        Map<Uuid, Set<Integer>> expectedTopicPartitionMap = Map.of(
            t1Uuid, Set.of(0, 1),
            t2Uuid, Set.of(0, 1),
            t3Uuid, Set.of(0, 1)
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(),
                Map.of(t1Uuid, t1Name, t2Uuid, t2Name, t3Uuid, t3Name)  // Existing deleting topics should be included here.
            )
        );

        List<CoordinatorRecord> records = new ArrayList<>();
        Optional<DeleteShareGroupStateParameters> params = context.groupMetadataManager.shareGroupBuildPartitionDeleteRequest(groupId, records);
        verifyShareGroupDeleteRequest(
            params,
            expectedTopicPartitionMap,
            groupId,
            true
        );
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testShareGroupDeleteRequestWithAlreadyDeletingTopicsButNotInMetadata() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .build();

        Uuid t1Uuid = Uuid.randomUuid();
        Uuid t2Uuid = Uuid.randomUuid();
        Uuid t3Uuid = Uuid.randomUuid();
        String t1Name = "t1";
        String t2Name = "t2";
        String t3Name = "t3";

        String groupId = "share-group";
        ShareGroup shareGroup = mock(ShareGroup.class);
        when(shareGroup.groupId()).thenReturn(groupId);
        when(shareGroup.isEmpty()).thenReturn(false);

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(t1Uuid, t1Name, 2)
            .addTopic(t2Uuid, t2Name, 2)
//            .addTopic(t3Uuid, t3Name, 2)  // Simulate deleting topic not present in metadata image.
            .buildCoordinatorMetadataImage();

        CoordinatorMetadataDelta delta = image.emptyDelta();
        context.groupMetadataManager.onMetadataUpdate(delta, image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        context.replay(
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(t1Uuid, new InitMapValue(t1Name, Set.of(0, 1), 1)),
                Map.of(t2Uuid, new InitMapValue(t2Name, Set.of(0, 1), 1)),
                Map.of(t3Uuid, t3Name)
            )
        );

        context.commit();

        Map<Uuid, Set<Integer>> expectedTopicPartitionMap = Map.of(
            t1Uuid, Set.of(0, 1),
            t2Uuid, Set.of(0, 1)
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(),
                Map.of(t1Uuid, t1Name, t2Uuid, t2Name)  // Existing deleting topics should be ignored.
            )
        );

        List<CoordinatorRecord> records = new ArrayList<>();
        Optional<DeleteShareGroupStateParameters> params = context.groupMetadataManager.shareGroupBuildPartitionDeleteRequest(groupId, records);
        verifyShareGroupDeleteRequest(
            params,
            expectedTopicPartitionMap,
            groupId,
            true
        );
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testShareGroupDeleteRequestWithAlreadyDeletingTopicsButMetadataIsEmpty() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .build();

        Uuid t1Uuid = Uuid.randomUuid();
        Uuid t2Uuid = Uuid.randomUuid();
        Uuid t3Uuid = Uuid.randomUuid();
        String t1Name = "t1";
        String t2Name = "t2";
        String t3Name = "t3";

        String groupId = "share-group";
        ShareGroup shareGroup = mock(ShareGroup.class);
        when(shareGroup.groupId()).thenReturn(groupId);
        when(shareGroup.isEmpty()).thenReturn(false);

        CoordinatorMetadataImage image = CoordinatorMetadataImage.EMPTY;
        CoordinatorMetadataDelta delta = image.emptyDelta();
        context.groupMetadataManager.onMetadataUpdate(delta, image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        context.replay(
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(t1Uuid, new InitMapValue(t1Name, Set.of(0, 1), 1)),
                Map.of(t2Uuid, new InitMapValue(t2Name, Set.of(0, 1), 1)),
                Map.of(t3Uuid, t3Name)
            )
        );

        context.commit();

        Map<Uuid, Set<Integer>> expectedTopicPartitionMap = Map.of(
            t1Uuid, Set.of(0, 1),
            t2Uuid, Set.of(0, 1)
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(),
                Map.of(t1Uuid, t1Name, t2Uuid, t2Name)  // Existing deleting topics should be ignored.
            )
        );

        List<CoordinatorRecord> records = new ArrayList<>();
        Optional<DeleteShareGroupStateParameters> params = context.groupMetadataManager.shareGroupBuildPartitionDeleteRequest(groupId, records);
        verifyShareGroupDeleteRequest(
            params,
            expectedTopicPartitionMap,
            groupId,
            true
        );
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testSharePartitionsEligibleForOffsetDeletionSuccess() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        String groupId = "share-group";
        String topicName1 = "topic-1";
        String topicName2 = "topic-2";
        Uuid topicId1 = Uuid.randomUuid();
        Uuid topicId2 = Uuid.randomUuid();

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(topicId1, topicName1, 3)
            .addTopic(topicId2, topicName2, 2)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        context.replay(
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(
                    topicId1, new InitMapValue(topicName1, Set.of(0, 1, 2), 1),
                    topicId2, new InitMapValue(topicName2, Set.of(0, 1), 1)
                ),
                Map.of()
            )
        );

        context.commit();

        List<DeleteShareGroupStateRequestData.DeleteStateData> expectedResult = List.of(
            new DeleteShareGroupStateRequestData.DeleteStateData()
                .setTopicId(topicId1)
                .setPartitions(List.of(
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(0),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(1),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(2)
                )),
            new DeleteShareGroupStateRequestData.DeleteStateData()
                .setTopicId(topicId2)
                .setPartitions(List.of(
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(0),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(1)
                ))
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(),
                Map.of(topicId1, topicName1, topicId2, topicName2)
            )
        );

        DeleteShareGroupOffsetsRequestData requestData = new DeleteShareGroupOffsetsRequestData()
            .setGroupId(groupId)
            .setTopics(List.of(
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName1),
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName2)
            ));
        List<DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> errorTopicResponseList = new ArrayList<>();

        List<CoordinatorRecord> records = new ArrayList<>();

        List<DeleteShareGroupStateRequestData.DeleteStateData> result =
            context.groupMetadataManager.sharePartitionsEligibleForOffsetDeletion(groupId, requestData, errorTopicResponseList, records);

        assertTrue(errorTopicResponseList.isEmpty());
        assertEquals(expectedResult, result);
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testSharePartitionsEligibleForOffsetDeletionContainsDeletingTopics() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        String groupId = "share-group";
        String topicName1 = "topic-1";
        String topicName2 = "topic-2";
        String topicName3 = "topic-3";
        String topicName4 = "topic-4";
        Uuid topicId1 = Uuid.randomUuid();
        Uuid topicId2 = Uuid.randomUuid();
        Uuid topicId3 = Uuid.randomUuid();
        Uuid topicId4 = Uuid.randomUuid();

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(topicId1, topicName1, 3)
            .addTopic(topicId2, topicName2, 2)
            .addTopic(topicId3, topicName3, 2)
            .addTopic(topicId4, topicName4, 2)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        context.replay(
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(
                    topicId1, new InitMapValue(topicName1, Set.of(0, 1, 2), 1),
                    topicId2, new InitMapValue(topicName2, Set.of(0, 1), 1)
                ),
                Map.of(
                    topicId3, topicName3,
                    topicId4, topicName4
                )
            )
        );

        context.commit();

        // Because "topic-4" not a part of the request data, it will not be added to the result, even though it is part
        // of the deletingTopics set. "topic-3" isn't currently initialized for the group, but since it is part of the
        // deletingTopics set, it will still be included in the result and tried to get deleted by the persister.
        List<DeleteShareGroupStateRequestData.DeleteStateData> expectedResult = List.of(
            new DeleteShareGroupStateRequestData.DeleteStateData()
                .setTopicId(topicId1)
                .setPartitions(List.of(
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(0),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(1),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(2)
                )),
            new DeleteShareGroupStateRequestData.DeleteStateData()
                .setTopicId(topicId2)
                .setPartitions(List.of(
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(0),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(1)
                )),
            new DeleteShareGroupStateRequestData.DeleteStateData()
                .setTopicId(topicId3)
                .setPartitions(List.of(
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(0),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(1)
                ))
        );

        // The ShareGroupStatePartitionMetadata record will contain all 4 topics in the deletingTopics list
        List<CoordinatorRecord> expectedRecords = List.of(
            newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(),
                Map.of(
                    topicId1, topicName1,
                    topicId2, topicName2,
                    topicId3, topicName3,
                    topicId4, topicName4
                )
            )
        );

        DeleteShareGroupOffsetsRequestData requestData = new DeleteShareGroupOffsetsRequestData()
            .setGroupId(groupId)
            .setTopics(List.of(
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName1),
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName2),
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName3)
            ));
        List<DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> errorTopicResponseList = new ArrayList<>();

        List<CoordinatorRecord> records = new ArrayList<>();

        List<DeleteShareGroupStateRequestData.DeleteStateData> result =
            context.groupMetadataManager.sharePartitionsEligibleForOffsetDeletion(groupId, requestData, errorTopicResponseList, records);

        assertTrue(errorTopicResponseList.isEmpty());
        assertEquals(expectedResult, result);
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testSharePartitionsEligibleForOffsetDeletionErrorTopics() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        String groupId = "share-group";
        String topicName1 = "topic-1";
        String topicName2 = "topic-2";
        Uuid topicId1 = Uuid.randomUuid();

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(topicId1, topicName1, 3)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        context.replay(
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(topicId1, new InitMapValue(topicName1, Set.of(0, 1, 2), 1)),
                Map.of()
            )
        );

        context.commit();

        List<DeleteShareGroupStateRequestData.DeleteStateData> expectedResult = List.of(
            new DeleteShareGroupStateRequestData.DeleteStateData()
                .setTopicId(topicId1)
                .setPartitions(List.of(
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(0),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(1),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(2)
                ))
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(),
                Map.of(topicId1, topicName1)
            )
        );

        DeleteShareGroupOffsetsRequestData requestData = new DeleteShareGroupOffsetsRequestData()
            .setGroupId(groupId)
            .setTopics(List.of(
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName1),
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName2)
            ));
        List<DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> errorTopicResponseList = new ArrayList<>();

        List<CoordinatorRecord> records = new ArrayList<>();

        List<DeleteShareGroupStateRequestData.DeleteStateData> result =
            context.groupMetadataManager.sharePartitionsEligibleForOffsetDeletion(groupId, requestData, errorTopicResponseList, records);

        assertEquals(
            List.of(
                new DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic()
                    .setTopicName(topicName2)
                    .setErrorCode(Errors.UNKNOWN_TOPIC_OR_PARTITION.code())
                    .setErrorMessage(Errors.UNKNOWN_TOPIC_OR_PARTITION.message())
            ),
            errorTopicResponseList
        );
        assertEquals(expectedResult, result);
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testSharePartitionsEligibleForOffsetDeletionUninitializedTopics() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        String groupId = "share-group";
        String topicName1 = "topic-1";
        String topicName2 = "topic-2";
        Uuid topicId1 = Uuid.randomUuid();
        Uuid topicId2 = Uuid.randomUuid();

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(topicId1, topicName1, 3)
            .addTopic(topicId2, topicName2, 2)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        context.replay(
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(topicId2, new InitMapValue(topicName2, Set.of(0, 1), 1)),
                Map.of(topicId1, new InitMapValue(topicName1, Set.of(0, 1, 2), 1)),
                Map.of()
            )
        );

        context.commit();

        List<DeleteShareGroupStateRequestData.DeleteStateData> expectedResult = List.of(
            new DeleteShareGroupStateRequestData.DeleteStateData()
                .setTopicId(topicId1)
                .setPartitions(List.of(
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(0),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(1),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(2)
                ))
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(topicId2, new InitMapValue(topicName2, Set.of(0, 1), 1)),
                Map.of(),
                Map.of(topicId1, topicName1)
            )
        );

        DeleteShareGroupOffsetsRequestData requestData = new DeleteShareGroupOffsetsRequestData()
            .setGroupId(groupId)
            .setTopics(List.of(
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName1),
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName2)
            ));
        List<DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> errorTopicResponseList = new ArrayList<>();

        List<CoordinatorRecord> records = new ArrayList<>();

        List<DeleteShareGroupStateRequestData.DeleteStateData> result =
            context.groupMetadataManager.sharePartitionsEligibleForOffsetDeletion(groupId, requestData, errorTopicResponseList, records);

        assertEquals(
            List.of(
                new DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic()
                    .setTopicName(topicName2)
                    .setErrorCode(Errors.UNKNOWN_TOPIC_OR_PARTITION.code())
                    .setErrorMessage("There is no offset information to delete.")
            ),
            errorTopicResponseList
        );
        assertEquals(expectedResult, result);
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testSharePartitionsEligibleForOffsetDeletionUninitializedAndErrorTopics() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        String groupId = "share-group";
        String topicName1 = "topic-1";
        String topicName2 = "topic-2";
        String topicName3 = "topic-3";
        Uuid topicId1 = Uuid.randomUuid();
        Uuid topicId2 = Uuid.randomUuid();

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(topicId1, topicName1, 3)
            .addTopic(topicId2, topicName2, 2)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        context.replay(
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(topicId2, new InitMapValue(topicName2, Set.of(0, 1), 1)),
                Map.of(topicId1, new InitMapValue(topicName1, Set.of(0, 1, 2), 1)),
                Map.of()
            )
        );

        context.commit();

        List<DeleteShareGroupStateRequestData.DeleteStateData> expectedResult = List.of(
            new DeleteShareGroupStateRequestData.DeleteStateData()
                .setTopicId(topicId1)
                .setPartitions(List.of(
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(0),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(1),
                    new DeleteShareGroupStateRequestData.PartitionData()
                        .setPartition(2)
                ))
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(topicId2, new InitMapValue(topicName2, Set.of(0, 1), 1)),
                Map.of(),
                Map.of(topicId1, topicName1)
            )
        );

        DeleteShareGroupOffsetsRequestData requestData = new DeleteShareGroupOffsetsRequestData()
            .setGroupId(groupId)
            .setTopics(List.of(
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName1),
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName2),
                new DeleteShareGroupOffsetsRequestData.DeleteShareGroupOffsetsRequestTopic()
                    .setTopicName(topicName3)
            ));
        List<DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> errorTopicResponseList = new ArrayList<>();

        List<CoordinatorRecord> records = new ArrayList<>();

        List<DeleteShareGroupStateRequestData.DeleteStateData> result =
            context.groupMetadataManager.sharePartitionsEligibleForOffsetDeletion(groupId, requestData, errorTopicResponseList, records);

        assertEquals(
            List.of(
                new DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic()
                    .setTopicName(topicName2)
                    .setErrorCode(Errors.UNKNOWN_TOPIC_OR_PARTITION.code())
                    .setErrorMessage("There is no offset information to delete."),
                new DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic()
                    .setTopicName(topicName3)
                    .setErrorCode(Errors.UNKNOWN_TOPIC_OR_PARTITION.code())
                    .setErrorMessage(Errors.UNKNOWN_TOPIC_OR_PARTITION.message())
            ),
            errorTopicResponseList
        );
        assertEquals(expectedResult, result);
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testCompleteDeleteShareGroupOffsets() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        String groupId = "share-group";
        String topicName1 = "topic-1";
        String topicName2 = "topic-2";
        Uuid topicId1 = Uuid.randomUuid();
        Uuid topicId2 = Uuid.randomUuid();

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(topicId1, topicName1, 3)
            .addTopic(topicId2, topicName2, 2)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        context.replay(
            GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(),
                Map.of(
                    topicId1, topicName1,
                    topicId2, topicName2
                )
            )
        );

        context.commit();

        List<DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> expectedResult = List.of(
            new DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic()
                .setTopicId(topicId1)
                .setTopicName(topicName1)
                .setErrorCode(Errors.NONE.code())
                .setErrorMessage(null),
            new DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic()
                .setTopicId(topicId2)
                .setTopicName(topicName2)
                .setErrorCode(Errors.NONE.code())
                .setErrorMessage(null)
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            newShareGroupStatePartitionMetadataRecord(
                groupId,
                Map.of(),
                Map.of(),
                Map.of()
            )
        );

        Map<Uuid, String> topics = Map.of(
            topicId1, topicName1,
            topicId2, topicName2
        );

        List<CoordinatorRecord> records = new ArrayList<>();

        List<DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> result =
            context.groupMetadataManager.completeDeleteShareGroupOffsets(groupId, topics, records);

        assertEquals(convertResponseTopicListToMap(expectedResult), convertResponseTopicListToMap(result));
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testCompleteDeleteShareGroupOffsetsEmptyResult() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        String groupId = "share-group";
        String topicName1 = "topic-1";
        String topicName2 = "topic-2";
        Uuid topicId1 = Uuid.randomUuid();
        Uuid topicId2 = Uuid.randomUuid();

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(topicId1, topicName1, 3)
            .addTopic(topicId2, topicName2, 2)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);

        context.replay(GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 0, 0));

        List<DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> expectedResult = List.of();

        List<CoordinatorRecord> expectedRecords = List.of();

        Map<Uuid, String> topics = Map.of(
            topicId1, topicName1,
            topicId2, topicName2
        );

        List<CoordinatorRecord> records = new ArrayList<>();

        List<DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> result =
            context.groupMetadataManager.completeDeleteShareGroupOffsets(groupId, topics, records);

        assertEquals(convertResponseTopicListToMap(expectedResult), convertResponseTopicListToMap(result));
        assertRecordsEquals(expectedRecords, records);
    }

    @Test
    public void testShareGroupHeartbeatInitializeOnPartitionUpdate() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        Uuid t1Uuid = Uuid.randomUuid();
        String t1Name = "t1";
        Uuid t2Uuid = Uuid.randomUuid();
        String t2Name = "t2";
        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(t1Uuid, "t1", 2)
            .addTopic(t2Uuid, "t2", 2)
            .buildCoordinatorMetadataImage();

        String groupId = "share-group";

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);

        Uuid memberId = Uuid.randomUuid();
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId.toString())
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of(t1Name, t2Name)));

        CoordinatorRecord expected = newShareGroupStatePartitionMetadataRecord(
            groupId,
            mkShareGroupStateMap(List.of(
                mkShareGroupStateMetadataEntry(t1Uuid, t1Name, List.of(0, 1)),
                mkShareGroupStateMetadataEntry(t2Uuid, t2Name, List.of(0, 1))
            )),
            Map.of(),
            Map.of()
        );

        Optional<CoordinatorRecord> actual = result.records().stream().filter(record -> record.key() instanceof ShareGroupStatePartitionMetadataKey)
            .findAny();
        assertTrue(actual.isPresent());
        assertRecordEquals(expected, actual.get());

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(
                t1Uuid,
                Set.of(0, 1),
                t2Uuid,
                Set.of(0, 1)
            ),
            groupId,
            2,
            true
        );

        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializingTopics(List.of())
                .setInitializedTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t1Uuid)
                        .setTopicName(t1Name)
                        .setPartitions(List.of(0, 1)),
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t2Uuid)
                        .setTopicName(t2Name)
                        .setPartitions(List.of(0, 1))
                ))
                .setDeletingTopics(List.of())
        );

        // Partition increase
        image = new MetadataImageBuilder()
            .addTopic(t1Uuid, "t1", 4)
            .addTopic(t2Uuid, "t2", 2)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);

        assignor.prepareGroupAssignment(new GroupAssignment(
            Map.of(
                memberId.toString(),
                new MemberAssignmentImpl(
                    Map.of(
                        t1Uuid,
                        Set.of(0, 1, 2, 3),
                        t2Uuid,
                        Set.of(0, 1)
                    )
                )
            )
        ));

        result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId.toString())
                .setMemberEpoch(2)
                .setSubscribedTopicNames(null));

        expected = newShareGroupStatePartitionMetadataRecord(
            groupId,
            mkShareGroupStateMap(List.of(
                mkShareGroupStateMetadataEntry(t1Uuid, t1Name, List.of(2, 3))
            )),
            mkShareGroupStateMap(List.of(
                mkShareGroupStateMetadataEntry(t1Uuid, t1Name, List.of(0, 1)),
                mkShareGroupStateMetadataEntry(t2Uuid, t2Name, List.of(0, 1))
            )),
            Map.of()
        );

        actual = result.records().stream().filter(record -> record.key() instanceof ShareGroupStatePartitionMetadataKey)
            .findAny();
        assertTrue(actual.isPresent());
        assertRecordEquals(expected, actual.get());

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(
                t1Uuid,
                Set.of(2, 3)
            ),
            groupId,
            3,
            true
        );

        assertEquals(Map.of(t1Uuid, Set.of(0, 1), t2Uuid, Set.of(0, 1)), context.groupMetadataManager.initializedShareGroupPartitions(groupId));
        verify(context.metrics, times(2)).record(SHARE_GROUP_REBALANCES_SENSOR_NAME);
    }

    @Test
    public void testShareGroupHeartbeatDoesNotBumpGroupEpochDuringAssignmentDelay() {
        Uuid t1Uuid = Uuid.randomUuid();
        String t1Name = "t1";
        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(t1Uuid, t1Name, 2)
            .buildCoordinatorMetadataImage();

        String groupId = "share-group";
        String memberId = Uuid.randomUuid().toString();

        ShareGroupMember member = new ShareGroupMember.Builder(memberId)
            .setState(MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(t1Name))
            .setAssignedPartitions(Map.of())
            .build();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withMetadataImage(metadataImage)
            .withShareGroup(new ShareGroupBuilder(groupId, 2)
                .withMember(member)
                .withAssignment(memberId, mkAssignment())
                .withAssignmentEpoch(2)
                // Suppress assignments.
                .withAssignmentTimestamp(Integer.MAX_VALUE)
                .withMetadataHash(computeGroupHash(Map.of(
                    t1Name, computeTopicHash(t1Name, metadataImage)
                ))))
            .build();

        // t1-0 and t1-1 are initialized and not yet assigned.
        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializingTopics(List.of())
                .setInitializedTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t1Uuid)
                        .setTopicName(t1Name)
                        .setPartitions(List.of(0, 1))
                ))
                .setDeletingTopics(List.of())
        );

        // Group epoch is bumped on the next heartbeat.
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result1 = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(2));

        assertEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 3, computeGroupHash(Map.of(
                    t1Name, computeTopicHash(t1Name, metadataImage)
                )))
            ),
            result1.records()
        );

        // Group epoch is not bumped again.
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result2 = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId)
                .setMemberEpoch(2));

        assertEquals(
            List.of(),
            result2.records()
        );
    }

    @Test
    public void testShareGroupHeartbeatPersisterRequestWithInitializing() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        MockTime time = new MockTime();
        int initRetryTimeoutMs = 10;
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withTime(time)
            .withConfig(GroupCoordinatorConfig.OFFSET_COMMIT_TIMEOUT_MS_CONFIG, initRetryTimeoutMs - 1)
            .withConfig(GroupCoordinatorConfig.SHARE_GROUP_INITIALIZE_RETRY_INTERVAL_MS_CONFIG, initRetryTimeoutMs)
            .build();

        Uuid t1Uuid = Uuid.randomUuid();
        String t1Name = "t1";
        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(t1Uuid, t1Name, 2)
            .buildCoordinatorMetadataImage();

        String groupId = "share-group";

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);
        context.groupMetadataManager.replay(
            new ShareGroupMetadataKey()
                .setGroupId(groupId),
            new ShareGroupMetadataValue()
                .setEpoch(1)
        );
        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializingTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t1Uuid)
                        .setTopicName(t1Name)
                        .setPartitions(List.of(0, 1))
                ))
                .setInitializedTopics(List.of())
                .setDeletingTopics(List.of())
        );

        Uuid memberId = Uuid.randomUuid();
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId.toString())
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of(t1Name)));

        assertFalse(result.records().contains(
            newShareGroupStatePartitionMetadataRecord(groupId, mkShareGroupStateMap(List.of(
                    mkShareGroupStateMetadataEntry(t1Uuid, t1Name, List.of(0, 1))
                )),
                Map.of(),
                Map.of()
            ))
        );

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(t1Uuid, Set.of(0, 1)),
            groupId,
            2,
            false
        );

        // Manipulate time so the initializing topic becomes eligible for retry
        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializingTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t1Uuid)
                        .setTopicName(t1Name)
                        .setPartitions(List.of(0, 1))
                ))
                .setInitializedTopics(List.of())
                .setDeletingTopics(List.of())
        );

        long timeNow = time.milliseconds() + initRetryTimeoutMs + 1;
        time.setCurrentTimeMs(timeNow);
        memberId = Uuid.randomUuid();
        result = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId.toString())
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of(t1Name)));

        assertTrue(result.records().contains(
            newShareGroupStatePartitionMetadataRecord(groupId, mkShareGroupStateMap(List.of(
                    mkShareGroupStateMetadataEntry(t1Uuid, t1Name, List.of(0, 1))
                )),
                Map.of(),
                Map.of()
            ))
        );

        verifyShareGroupHeartbeatInitializeRequest(
            result.response().getValue(),
            Map.of(t1Uuid, Set.of(0, 1)),
            groupId,
            3,
            true
        );
        verify(context.metrics, times(2)).record(SHARE_GROUP_REBALANCES_SENSOR_NAME);
    }

    @Test
    public void testShareGroupInitializingClearsCommonDeleting() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        Uuid t1Uuid = Uuid.randomUuid();
        String t1Name = "t1";
        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(t1Uuid, t1Name, 2)
            .buildCoordinatorMetadataImage();

        String groupId = "share-group";

        context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class), image);
        context.groupMetadataManager.replay(
            new ShareGroupMetadataKey()
                .setGroupId(groupId),
            new ShareGroupMetadataValue()
                .setEpoch(0)
        );

        // Replay a deleting record.
        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializingTopics(List.of())
                .setInitializedTopics(List.of())
                .setDeletingTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicInfo()
                        .setTopicId(t1Uuid)
                        .setTopicName(t1Name)
                ))
        );

        List<CoordinatorRecord> records = new ArrayList<>();
        context.groupMetadataManager.addInitializingTopicsRecords(groupId, records, Map.of(t1Uuid, new InitMapValue(t1Name, Set.of(0, 1), 1)));

        List<CoordinatorRecord> expectedRecords = List.of(
            CoordinatorRecord.record(
                new ShareGroupStatePartitionMetadataKey()
                    .setGroupId(groupId),
                new ApiMessageAndVersion(
                    new ShareGroupStatePartitionMetadataValue()
                        .setInitializingTopics(List.of(
                            new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                                .setTopicId(t1Uuid)
                                .setTopicName(t1Name)
                                .setPartitions(List.of(0, 1))
                        ))
                        .setInitializedTopics(List.of())
                        .setDeletingTopics(List.of()),
                    (short) 0
                )
            )
        );

        assertEquals(expectedRecords, records);
    }

    @Test
    public void testShareGroupInitializeSuccess() {
        String groupId = "groupId";
        Uuid topicId = Uuid.randomUuid();
        String topicName = "t1";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(topicId, topicName, 2)
                .buildCoordinatorMetadataImage()
            )
            .build();

        context.groupMetadataManager.replay(
            new ShareGroupMetadataKey()
                .setGroupId(groupId),
            new ShareGroupMetadataValue()
                .setEpoch(0)
        );

        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializingTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(topicId)
                        .setTopicName(topicName)
                        .setPartitions(List.of(0, 1))
                ))
                .setInitializedTopics(List.of())
                .setDeletingTopics(List.of())
        );

        Map<Uuid, Set<Integer>> snapshotMetadataInitializeMap = Map.of(
            topicId,
            Set.of(0, 1)
        );

        Map<Uuid, InitMapValue> snapshotMetadataInitializeRecordMap = Map.of(
            topicId,
            new InitMapValue(
                topicName,
                Set.of(0, 1),
                context.time.milliseconds()
            )
        );

        CoordinatorResult<Void, CoordinatorRecord> result = context.groupMetadataManager.initializeShareGroupState(groupId, snapshotMetadataInitializeMap);

        CoordinatorRecord record = newShareGroupStatePartitionMetadataRecord(groupId, Map.of(), snapshotMetadataInitializeRecordMap, Map.of());

        assertNull(result.response());
        assertEquals(1, result.records().size());
        assertRecordEquals(record, result.records().get(0));
        // Make sure the timeline map is not modified yet.
        assertEquals(snapshotMetadataInitializeRecordMap, context.groupMetadataManager.shareGroupStatePartitionMetadata().get(groupId).initializingTopics());
    }

    @Test
    public void testShareGroupInitializeEmptyMap() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("range");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        String groupId = "groupId";
        context.groupMetadataManager.replay(
            new ShareGroupMetadataKey()
                .setGroupId(groupId),
            new ShareGroupMetadataValue()
                .setEpoch(0)
        );

        CoordinatorResult<Void, CoordinatorRecord> result = context.groupMetadataManager.initializeShareGroupState(groupId, Map.of());

        assertNull(result.response());
        assertEquals(List.of(), result.records());

        result = context.groupMetadataManager.initializeShareGroupState(groupId, null);

        assertNull(result.response());
        assertEquals(List.of(), result.records());

        assertEquals(Map.of(), context.groupMetadataManager.initializedShareGroupPartitions(groupId));
    }

    @Test
    public void testSubscribedTopicsChangeMap() {
        String topicName = "foo";
        Uuid topicId = Uuid.randomUuid();
        int partitions = 1;
        String groupId = "foogrp";
        MockTime time = new MockTime();
        int initRetryTimeoutMs = 10;

        MockPartitionAssignor assignor = new MockPartitionAssignor("simple");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .withTime(time)
            .withConfig(GroupCoordinatorConfig.OFFSET_COMMIT_TIMEOUT_MS_CONFIG, initRetryTimeoutMs - 1)
            .withConfig(GroupCoordinatorConfig.SHARE_GROUP_INITIALIZE_RETRY_INTERVAL_MS_CONFIG, initRetryTimeoutMs)
            .withMetadataImage(new MetadataImageBuilder()
                .addTopic(topicId, topicName, partitions)
                .buildCoordinatorMetadataImage())
            .build();

        // Empty on empty subscription topics
        assertEquals(
            Map.of(),
            context.groupMetadataManager.subscribedTopicsChangeMap(groupId, Set.of())
        );

        long timeNow = time.milliseconds() + 100;
        time.setCurrentTimeMs(timeNow);
        assertEquals(
            Map.of(
                topicId, new InitMapValue(topicName, Set.of(0), timeNow)
            ),
            context.groupMetadataManager.subscribedTopicsChangeMap(groupId, Set.of(
                topicName
            ))
        );

        // Calculates correct diff respecting both initialized and initializing maps.
        String t1Name = "t1";
        Uuid t1Id = Uuid.randomUuid();
        String t2Name = "t2";
        Uuid t2Id = Uuid.randomUuid();
        String t3Name = "t3";
        Uuid t3Id = Uuid.randomUuid();

        context.groupMetadataManager.replay(
            new ShareGroupMetadataKey()
                .setGroupId(groupId),
            new ShareGroupMetadataValue()
                .setEpoch(0)
        );

        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializingTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t1Id)
                        .setTopicName(t1Name)
                        .setPartitions(List.of(0, 1))
                ))
                .setInitializedTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t2Id)
                        .setTopicName(t2Name)
                        .setPartitions(List.of(0, 1, 2))
                ))
                .setDeletingTopics(List.of())
        );

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(t1Id, t1Name, 2)
            .addTopic(t2Id, t2Name, 2)
            .addTopic(t3Id, t3Name, 3)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(metadataImage.emptyDelta(), metadataImage);

        // Since t1 is initializing and t2 is initialized due to replay above.
        timeNow = timeNow + initRetryTimeoutMs + 1;
        time.setCurrentTimeMs(timeNow);
        assertEquals(
            Map.of(
                t1Id, new InitMapValue(t1Name, Set.of(0, 1), timeNow),      // initializing
                t3Id, new InitMapValue(t3Name, Set.of(0, 1, 2), timeNow)    // initialized
            ),
            context.groupMetadataManager.subscribedTopicsChangeMap(groupId, Set.of(
                t1Name,
                t2Name,
                t3Name
            ))
        );

        assertEquals(Map.of(t2Id, Set.of(0, 1, 2)), context.groupMetadataManager.initializedShareGroupPartitions(groupId));
    }

    @Test
    public void testUninitializeTopics() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("simple");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        String groupId = "shareGroupId";
        Uuid t1Id = Uuid.randomUuid();
        String t1Name = "t1Name";
        Uuid t2Id = Uuid.randomUuid();
        String t2Name = "t2Name";

        // No records if topics to be uninitialized are not in metadata info.
        CoordinatorResult<Void, CoordinatorRecord> result = context.groupMetadataManager.uninitializeShareGroupState(groupId, Map.of(t1Id, Set.of(0)));
        assertEquals(
            List.of(),
            result.records()
        );

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(t1Id, t1Name, 2)
            .addTopic(t2Id, t2Name, 3)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(image.emptyDelta(), image);

        // Cleanup happens from initializing state only.
        context.groupMetadataManager.replay(
            new ShareGroupMetadataKey()
                .setGroupId(groupId),
            new ShareGroupMetadataValue()
                .setEpoch(0)
        );
        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializingTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t1Id)
                        .setTopicName(t1Name)
                        .setPartitions(List.of(0, 1))
                ))
                .setInitializedTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t2Id)
                        .setTopicName(t2Name)
                        .setPartitions(List.of(0, 1, 2))
                ))
                .setDeletingTopics(List.of())
        );
        result = context.groupMetadataManager.uninitializeShareGroupState(groupId, Map.of(t1Id, Set.of(0, 1)));
        Set<Integer> partitions = new LinkedHashSet<>(List.of(0, 1, 2));
        assertEquals(
            List.of(newShareGroupStatePartitionMetadataRecord(groupId, Map.of(), Map.of(t2Id, new InitMapValue(t2Name, partitions, 1)), Map.of())),
            result.records()
        );
    }

    @Test
    public void testMaybeCleanupShareGroupStateEmptyTopicIds() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("simple");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        CoordinatorResult<Void, CoordinatorRecord> expectedResults = new CoordinatorResult<>(List.of());
        assertEquals(expectedResults, context.groupMetadataManager.maybeCleanupShareGroupState(Set.of()));

        context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        Set<Uuid> topicIds = Set.of(Uuid.randomUuid());
        assertEquals(expectedResults, context.groupMetadataManager.maybeCleanupShareGroupState(topicIds));
    }

    @Test
    public void testMaybeCleanupShareGroupStateInitDeletedTopicsPresent() {
        MockPartitionAssignor assignor = new MockPartitionAssignor("simple");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        String groupId = "sharegroup";
        Uuid t1Id = Uuid.randomUuid();
        String t1Name = "t1";
        Uuid t2Id = Uuid.randomUuid();
        String t2Name = "t2";
        Uuid t3Id = Uuid.randomUuid();
        String t3Name = "t3";
        Uuid t4Id = Uuid.randomUuid();
        String t4Name = "t4";
        Uuid t5Id = Uuid.randomUuid();
        String t5Name = "t5";
        Uuid t6Id = Uuid.randomUuid();
        String t6Name = "t6";

        CoordinatorMetadataImage image = new MetadataImageBuilder()
            .addTopic(t1Id, t1Name, 2)
            .addTopic(t2Id, t2Name, 3)
            .addTopic(t3Id, t3Name, 3)
            .addTopic(t4Id, t4Name, 3)
            .addTopic(t5Id, t5Name, 3)
            .addTopic(t6Id, t6Name, 3)
            .buildCoordinatorMetadataImage();

        context.groupMetadataManager.onMetadataUpdate(image.emptyDelta(), image);

        context.groupMetadataManager.replay(
            new ShareGroupMetadataKey()
                .setGroupId(groupId),
            new ShareGroupMetadataValue()
                .setEpoch(0)
        );
        context.groupMetadataManager.replay(
            new ShareGroupStatePartitionMetadataKey()
                .setGroupId(groupId),
            new ShareGroupStatePartitionMetadataValue()
                .setInitializingTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t1Id)
                        .setTopicName(t1Name)
                        .setPartitions(List.of(0, 1)),
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t3Id)
                        .setTopicName(t3Name)
                        .setPartitions(List.of(0, 1, 2))
                ))
                .setInitializedTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t2Id)
                        .setTopicName(t2Name)
                        .setPartitions(List.of(0, 1, 2)),
                    new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                        .setTopicId(t4Id)
                        .setTopicName(t4Name)
                        .setPartitions(List.of(0, 1, 2))
                ))
                .setDeletingTopics(List.of(
                    new ShareGroupStatePartitionMetadataValue.TopicInfo()
                        .setTopicId(t5Id)
                        .setTopicName(t5Name),
                    new ShareGroupStatePartitionMetadataValue.TopicInfo()
                        .setTopicId(t6Id)
                        .setTopicName(t6Name)
                ))
        );

        List<CoordinatorRecord> expectedRecords = List.of(
            CoordinatorRecord.record(
                new ShareGroupStatePartitionMetadataKey()
                    .setGroupId(groupId),
                new ApiMessageAndVersion(
                    new ShareGroupStatePartitionMetadataValue()
                        .setInitializingTopics(List.of(
                            new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                                .setTopicId(t3Id)
                                .setTopicName(t3Name)
                                .setPartitions(List.of(0, 1, 2))
                        ))
                        .setInitializedTopics(List.of(
                            new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                                .setTopicId(t4Id)
                                .setTopicName(t4Name)
                                .setPartitions(List.of(0, 1, 2))
                        ))
                        .setDeletingTopics(List.of(
                            new ShareGroupStatePartitionMetadataValue.TopicInfo()
                                .setTopicId(t5Id)
                                .setTopicName(t5Name)
                        )),
                    (short) 0
                )
            )
        );

        CoordinatorResult<Void, CoordinatorRecord> expectedResult = new CoordinatorResult<>(expectedRecords);
        assertEquals(expectedResult, context.groupMetadataManager.maybeCleanupShareGroupState(Set.of(t1Id, t2Id, t6Id)));
    }

    @Test
    public void testCombineInitMaps() {
        // Both empty.
        Map<Uuid, InitMapValue> m1 = Map.of();
        Map<Uuid, InitMapValue> m2 = Map.of();

        assertEquals(Map.of(), GroupMetadataManager.combineInitMaps(m1, m2));

        Uuid t1Id = Uuid.randomUuid();
        String t1Name = "t1";
        Uuid t2Id = Uuid.randomUuid();
        String t2Name = "t2";

        // m1 non-empty, m2 empty.
        m1 = Map.of(t1Id, new InitMapValue(t1Name, Set.of(0), 1));
        assertEquals(m1, GroupMetadataManager.combineInitMaps(m1, m2));

        // m1 empty, m2 non-empty.
        m1 = Map.of();
        m2 = Map.of(t1Id, new InitMapValue(t1Name, Set.of(0), 1));
        assertEquals(m2, GroupMetadataManager.combineInitMaps(m1, m2));

        // m1 non-empty, m2 non-empty.
        m1 = Map.of(t1Id, new InitMapValue(t1Name, Set.of(0), 1));
        m2 = Map.of(t2Id, new InitMapValue(t2Name, Set.of(0), 1));
        assertEquals(Map.of(
            t1Id, new InitMapValue(t1Name, Set.of(0), 1),
            t2Id, new InitMapValue(t2Name, Set.of(0), 1)
        ), GroupMetadataManager.combineInitMaps(m1, m2));

        // m1 non-empty, m2 non-empty (differ by partition)
        m1 = Map.of(t1Id, new InitMapValue(t1Name, Set.of(0), 1));
        m2 = Map.of(t1Id, new InitMapValue(t1Name, Set.of(1), 2));
        assertEquals(Map.of(t1Id, new InitMapValue(t1Name, Set.of(0, 1), 2)), GroupMetadataManager.combineInitMaps(m1, m2));

        // m1 and m2 exactly same
        m1 = Map.of(t1Id, new InitMapValue(t1Name, Set.of(0), 1));
        m2 = Map.of(t1Id, new InitMapValue(t1Name, Set.of(0), 1));
        assertEquals(Map.of(t1Id, new InitMapValue(t1Name, Set.of(0), 1)), GroupMetadataManager.combineInitMaps(m1, m2));
    }

    private static Stream<CoordinatorRecord> shareGroupRecords() {
        String groupId = "groupId";
        String memberId = Uuid.randomUuid().toString();

        return Stream.of(
            // Tombstones
            CoordinatorRecord.tombstone(
                new ShareGroupMemberMetadataKey()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
            ),
            CoordinatorRecord.tombstone(
                new ShareGroupMetadataKey()
                    .setGroupId(groupId)
            ),
            CoordinatorRecord.tombstone(
                new ShareGroupTargetAssignmentMemberKey()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
            ),
            CoordinatorRecord.tombstone(
                new ShareGroupTargetAssignmentMetadataKey()
                    .setGroupId(groupId)
            ),
            CoordinatorRecord.tombstone(
                new ShareGroupCurrentMemberAssignmentKey()
                    .setGroupId(groupId)
                    .setMemberId(memberId)
            ),
            CoordinatorRecord.tombstone(
                new ShareGroupStatePartitionMetadataKey()
                    .setGroupId(groupId)
            ),
            // Data
            CoordinatorRecord.record(
                new ShareGroupMemberMetadataKey()
                    .setGroupId(groupId)
                    .setMemberId(memberId),
                new ApiMessageAndVersion(
                    new ShareGroupMemberMetadataValue()
                        .setSubscribedTopicNames(List.of("tp1")),
                    (short) 10
                )
            ),
            CoordinatorRecord.record(
                new ShareGroupMetadataKey()
                    .setGroupId(groupId),
                new ApiMessageAndVersion(
                    new ShareGroupMetadataValue()
                        .setEpoch(1)
                        .setMetadataHash(2L),
                    (short) 11
                )
            ),
            CoordinatorRecord.record(
                new ShareGroupTargetAssignmentMetadataKey()
                    .setGroupId(groupId),
                new ApiMessageAndVersion(
                    new ShareGroupTargetAssignmentMetadataValue()
                        .setAssignmentEpoch(5),
                    (short) 12
                )
            ),
            CoordinatorRecord.record(
                new ShareGroupTargetAssignmentMemberKey()
                    .setGroupId(groupId)
                    .setMemberId(memberId),
                new ApiMessageAndVersion(new ShareGroupTargetAssignmentMemberValue()
                    .setTopicPartitions(List.of(
                        new ShareGroupTargetAssignmentMemberValue.TopicPartition()
                            .setTopicId(Uuid.randomUuid())
                            .setPartitions(List.of(0, 1, 2))
                    )),
                    (short) 13
                )
            ),
            CoordinatorRecord.record(
                new ShareGroupCurrentMemberAssignmentKey()
                    .setGroupId(groupId)
                    .setMemberId(memberId),
                new ApiMessageAndVersion(new ShareGroupCurrentMemberAssignmentValue()
                    .setAssignedPartitions(List.of(
                            new ShareGroupCurrentMemberAssignmentValue.TopicPartitions()
                                .setTopicId(Uuid.randomUuid())
                                .setPartitions(List.of(0, 1, 2))
                        )
                    )
                    .setMemberEpoch(5)
                    .setPreviousMemberEpoch(4)
                    .setState((byte) 0),
                    (short) 14
                )
            ),
            CoordinatorRecord.record(
                new ShareGroupStatePartitionMetadataKey()
                    .setGroupId(groupId),
                new ApiMessageAndVersion(new ShareGroupStatePartitionMetadataValue()
                    .setInitializingTopics(List.of())
                    .setInitializedTopics(List.of())
                    .setDeletingTopics(List.of()),
                    (short) 15
                )
            )
        );
    }

    @ParameterizedTest
    @MethodSource("shareGroupRecords")
    public void testShareGroupRecordsNoExceptionOnReplay(CoordinatorRecord record) {
        MockPartitionAssignor assignor = new MockPartitionAssignor("simple");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = spy(new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build());

        assertDoesNotThrow(() -> context.replay(record));
    }

    @Test
    public void testReplayConsumerGroupCurrentMemberAssignmentWithCompaction() {
        String groupId = "fooup";
        String memberIdA = "memberIdA";
        String memberIdB = "memberIdB";
        Uuid topicId = Uuid.randomUuid();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();

        // This test enacts the following scenario:
        // 1. Member A is assigned partition 0.
        // 2. Member A is unassigned partition 0 [record removed by compaction].
        // 3. Member B is assigned partition 0. 
        // 4. Member A is assigned partition 1. 
        // If record 2 is processed, there are no issues, however with compaction it is possible that 
        // unassignment records are removed. We would like to not fail in these cases.
        // Therefore we will allow assignments to owned partitions as long as the epoch is larger. 

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberIdA)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(topicId, 0)), 11))
            .build()));

        // Partition 0's owner is replaced by member B at epoch 12.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberIdB)
            .setState(MemberState.STABLE)
            .setMemberEpoch(12)
            .setPreviousMemberEpoch(11)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(topicId, 0)), 12))
            .build()));

        // Partition 0 must remain with member B at epoch 12 even though member A has just been unassigned partition 0.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberIdA)
            .setState(MemberState.STABLE)
            .setMemberEpoch(13)
            .setPreviousMemberEpoch(12)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(topicId, 1)), 13))
            .build()));

        // Verify partition epochs.
        ConsumerGroup group = context.groupMetadataManager.consumerGroup(groupId);
        assertEquals(12, group.currentPartitionEpoch(topicId, 0));
        assertEquals(13, group.currentPartitionEpoch(topicId, 1));
    }

    @Test
    public void testReplayConsumerGroupCurrentMemberAssignmentUnownedTopicWithCompaction() {
        String groupId = "fooup";
        String memberIdA = "memberIdA";
        String memberIdB = "memberIdB";
        Uuid fooTopicId = Uuid.randomUuid();
        Uuid barTopicId = Uuid.randomUuid();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();

        // This test enacts the following scenario:
        // 1. Member A is assigned partition foo-0.
        // 2. Member A is unassigned partition foo-0 [record removed by compaction].
        // 3. Member B is assigned partition foo-0.
        // 4. Member B is unassigned partition foo-0. 
        // 5. Member A is assigned partition bar-0. 
        // This is a legitimate set of assignments but with compaction the unassignment record can be skipped.
        // This can lead to conflicts from updating an owned partition in step 3 and attempting 
        // to remove nonexistent ownership in step 5. We want to ensure removing ownership from a 
        // completely unowned partition in step 5 is allowed.  

        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberIdA)
            .setState(MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0)), 11))
            .build()));

        // foo-0's owner is replaced by member B at epoch 12.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberIdB)
            .setState(MemberState.STABLE)
            .setMemberEpoch(12)
            .setPreviousMemberEpoch(11)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(fooTopicId, 0)), 11))
            .build()));

        // foo becomes unowned.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberIdB)
            .setState(MemberState.STABLE)
            .setMemberEpoch(13)
            .setPreviousMemberEpoch(12)
            .build()));

        // Member A is unassigned foo-0.
        context.replay(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, new ConsumerGroupMember.Builder(memberIdA)
            .setState(MemberState.STABLE)
            .setMemberEpoch(14)
            .setPreviousMemberEpoch(13)
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(mkTopicAssignment(barTopicId, 0)), 14))
            .build()));

        // Verify foo-0 is unowned and bar-0 is owned by member A at epoch 14.
        ConsumerGroup group = context.groupMetadataManager.consumerGroup(groupId);
        assertEquals(-1, group.currentPartitionEpoch(fooTopicId, 0));
        assertEquals(14, group.currentPartitionEpoch(barTopicId, 0));
    }

    @Test
    public void testReplayStreamsGroupCurrentMemberAssignmentWithCompaction() {
        String groupId = "fooup";
        String memberIdA = "memberIdA";
        String memberIdB = "memberIdB";
        String processIdA = "processIdA";
        String processIdB = "processIdB";
        String subtopologyId = "subtopology";

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
        // Initialize members with process Ids.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, 
            streamsGroupMemberBuilderWithDefaults(memberIdA)
                .setProcessId(processIdA)
                .build()));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, 
            streamsGroupMemberBuilderWithDefaults(memberIdB)
                .setProcessId(processIdB)
                .build()));

        // This test enacts the following scenario:
        // 1. Member A is assigned task 0.
        // 2. Member A is unassigned task 0 [record removed by compaction].
        // 3. Member B is assigned task 0. 
        // 4. Member A is assigned task 1. 
        // If record 2 is processed, there are no issues, however with compaction it is possible that 
        // unassignment records are removed. We would like to not fail in these cases.
        // Therefore we will allow assignments to owned tasks as long as the epoch is larger.

        // Assign task 0 to member A.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberIdA)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedTasks(TaskAssignmentTestUtil.mkTasksTupleWithEpochs(TaskRole.ACTIVE, 
                    TaskAssignmentTestUtil.mkTasksWithEpochs(subtopologyId, Map.of(0, 11))))
            .build()));

        // Task 0's owner is replaced by member B at epoch 12.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberIdB)
            .setMemberEpoch(12)
            .setPreviousMemberEpoch(11)
            .setAssignedTasks(TaskAssignmentTestUtil.mkTasksTupleWithEpochs(TaskRole.ACTIVE, 
                    TaskAssignmentTestUtil.mkTasksWithEpochs(subtopologyId, Map.of(0, 12))))
            .build()));

        // Task 0 must remain with member B at epoch 12 even though member A has just been unassigned task 0.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberIdA)
            .setMemberEpoch(13)
            .setPreviousMemberEpoch(12)
            .setAssignedTasks(TaskAssignmentTestUtil.mkTasksTupleWithEpochs(TaskRole.ACTIVE, 
                    TaskAssignmentTestUtil.mkTasksWithEpochs(subtopologyId, Map.of(1, 13))))
            .build()));

        // Verify task 1 is assigned to member A and task 0 to member B.
        StreamsGroup group = context.groupMetadataManager.streamsGroup(groupId);
        assertEquals(processIdA, group.currentActiveTaskProcessId(subtopologyId, 1));
        assertEquals(processIdB, group.currentActiveTaskProcessId(subtopologyId, 0));
    }

    @Test
    public void testReplayStreamsGroupCurrentMemberAssignmentUnownedTopologyWithCompaction() {
        String groupId = "fooup";
        String memberIdA = "memberIdA";
        String memberIdB = "memberIdB";
        String processIdA = "processIdA";
        String processIdB = "processIdB";
        String subtopologyFoo = "subtopologyFoo";
        String subtopologyBar = "subtopologyBar";

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
        // Initialize members with process Ids.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, 
            streamsGroupMemberBuilderWithDefaults(memberIdA)
                .setProcessId(processIdA)
                .build()));
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, 
            streamsGroupMemberBuilderWithDefaults(memberIdB)
                .setProcessId(processIdB)
                .build()));

        // This test enacts the following scenario:
        // 1. Member A is assigned task foo-0.
        // 2. Member A is unassigned task foo-0 [record removed by compaction].
        // 3. Member B is assigned task foo-0.
        // 4. Member B is unassigned task foo-0. 
        // 5. Member A is assigned task bar-0. 
        // This is a legitimate set of assignments but with compaction the unassignment record can be skipped.
        // This can lead to conflicts from updating an owned subtopology in step 3 and attempting to remove
        // nonexistent ownership in step 5. We want to ensure removing ownership from a 
        // completely unowned subtopology in step 5 is allowed.  

        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberIdA)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(11)
            .setPreviousMemberEpoch(10)
            .setAssignedTasks(TaskAssignmentTestUtil.mkTasksTupleWithEpochs(TaskRole.ACTIVE, 
                TaskAssignmentTestUtil.mkTasksWithEpochs(subtopologyFoo, Map.of(0, 11))))
            .build()));

        // foo-0's owner is replaced by member B at epoch 12.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberIdB)
            .setMemberEpoch(12)
            .setPreviousMemberEpoch(11)
            .setAssignedTasks(TaskAssignmentTestUtil.mkTasksTupleWithEpochs(TaskRole.ACTIVE, 
                TaskAssignmentTestUtil.mkTasksWithEpochs(subtopologyFoo, Map.of(0, 12))))
            .build()));

        // foo becomes unowned
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberIdB)
            .setMemberEpoch(13)
            .setPreviousMemberEpoch(12)
            .build()));

        // Member A is unassigned foo-0.
        context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, streamsGroupMemberBuilderWithDefaults(memberIdA)
            .setMemberEpoch(14)
            .setPreviousMemberEpoch(13)
            .setAssignedTasks(TaskAssignmentTestUtil.mkTasksTupleWithEpochs(TaskRole.ACTIVE, 
                TaskAssignmentTestUtil.mkTasksWithEpochs(subtopologyBar, Map.of(0, 14))))
            .build()));

        // Verify foo-0 is unassigned and bar-0 is assigned to member A.
        StreamsGroup group = context.groupMetadataManager.streamsGroup(groupId);
        assertNull(group.currentActiveTaskProcessId(subtopologyFoo, 0));
        assertEquals(processIdA, group.currentActiveTaskProcessId(subtopologyBar, 0));
    }

    private record PendingAssignmentCase(
        String description,
        String groupId,
        ShareGroup group,
        boolean expectedValue,
        Runnable assertions
    ) {
    }

    private static Stream<Function<GroupMetadataManagerTestContext, PendingAssignmentCase>> generatePendingAssignmentCases() {
        String groupId1 = "groupId";
        Uuid tid1 = Uuid.randomUuid();
        String tName1 = "t1";
        Uuid tid2 = Uuid.randomUuid();
        String tName2 = "t2";

        return Stream.of(
            (GroupMetadataManagerTestContext context) -> {
                ShareGroup group = mock(ShareGroup.class);
                when(group.isEmpty()).thenReturn(true);
                return new PendingAssignmentCase("Group is empty", groupId1, group, false, () -> {
                    verify(group, times(0)).groupId();
                    verify(group).isEmpty();
                });
            },
            (GroupMetadataManagerTestContext context) -> {
                ShareGroup group = mock(ShareGroup.class);
                when(group.groupId()).thenReturn(groupId1);
                when(group.isEmpty()).thenReturn(false);
                return new PendingAssignmentCase("Group not in metadata", groupId1, group, false, () -> {
                    verify(group).groupId();
                    verify(group).isEmpty();
                });
            },
            (GroupMetadataManagerTestContext context) -> {
                ShareGroup group = mock(ShareGroup.class);
                when(group.groupId()).thenReturn(groupId1);
                when(group.isEmpty()).thenReturn(false);
                context.groupMetadataManager.replay(
                    new ShareGroupStatePartitionMetadataKey()
                        .setGroupId(groupId1),
                    new ShareGroupStatePartitionMetadataValue()
                );
                context.commit();
                return new PendingAssignmentCase("Group metadata initialized topics empty", groupId1, group, false, () -> {
                    verify(group).groupId();
                    verify(group).isEmpty();
                });
            },
            (GroupMetadataManagerTestContext context) -> {
                ShareGroup group = mock(ShareGroup.class);
                when(group.groupId()).thenReturn(groupId1);
                when(group.isEmpty()).thenReturn(false);
                when(group.subscribedTopicNames()).thenReturn(Map.of());
                context.groupMetadataManager.replay(
                    new ShareGroupStatePartitionMetadataKey()
                        .setGroupId(groupId1),
                    new ShareGroupStatePartitionMetadataValue()
                        .setInitializedTopics(List.of(
                            new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                                .setTopicName(tName1)
                                .setTopicId(tid1)
                                .setPartitions(List.of(0, 1))
                        ))
                );
                context.commit();
                return new PendingAssignmentCase("Empty group subscription", groupId1, group, false, () -> {
                    verify(group).groupId();
                    verify(group).isEmpty();
                    verify(group).subscribedTopicNames();
                });
            },
            (GroupMetadataManagerTestContext context) -> {
                ShareGroup group = mock(ShareGroup.class);
                when(group.groupId()).thenReturn(groupId1);
                when(group.isEmpty()).thenReturn(false);
                when(group.subscribedTopicNames()).thenReturn(Map.of(tName2, new SubscriptionCount(1, 1)));
                when(group.targetAssignment()).thenReturn(Map.of());
                context.groupMetadataManager.replay(
                    new ShareGroupStatePartitionMetadataKey()
                        .setGroupId(groupId1),
                    new ShareGroupStatePartitionMetadataValue()
                        .setInitializedTopics(List.of(
                            new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                                .setTopicName(tName1)
                                .setTopicId(tid1)
                                .setPartitions(List.of(0, 1))
                        ))
                );
                context.commit();
                return new PendingAssignmentCase("Subscribed topics not in metadata and empty assignment.", groupId1, group, false, () -> {
                    verify(group).groupId();
                    verify(group).isEmpty();
                    verify(group).subscribedTopicNames();
                    verify(group).targetAssignment();
                });
            },
            (GroupMetadataManagerTestContext context) -> {
                ShareGroup group = mock(ShareGroup.class);
                when(group.groupId()).thenReturn(groupId1);
                when(group.isEmpty()).thenReturn(false);
                when(group.subscribedTopicNames()).thenReturn(Map.of(tName1, new SubscriptionCount(1, 1)));
                when(group.targetAssignment()).thenReturn(Map.of(tName1, new Assignment(Map.of(tid1, Set.of(0, 1)))));
                context.groupMetadataManager.replay(
                    new ShareGroupStatePartitionMetadataKey()
                        .setGroupId(groupId1),
                    new ShareGroupStatePartitionMetadataValue()
                        .setInitializedTopics(List.of(
                            new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                                .setTopicName(tName1)
                                .setTopicId(tid1)
                                .setPartitions(List.of(0, 1))
                        ))
                );
                context.commit();
                return new PendingAssignmentCase("Subscribed topics in metadata and assigned partitions match.", groupId1, group, false, () -> {
                    verify(group).groupId();
                    verify(group).isEmpty();
                    verify(group).subscribedTopicNames();
                    verify(group).targetAssignment();
                });
            },
            (GroupMetadataManagerTestContext context) -> {
                ShareGroup group = mock(ShareGroup.class);
                when(group.groupId()).thenReturn(groupId1);
                when(group.isEmpty()).thenReturn(false);
                when(group.subscribedTopicNames()).thenReturn(Map.of(tName1, new SubscriptionCount(1, 1)));
                when(group.targetAssignment()).thenReturn(Map.of(tName1, new Assignment(Map.of(tid1, Set.of(0)))));
                context.groupMetadataManager.replay(
                    new ShareGroupStatePartitionMetadataKey()
                        .setGroupId(groupId1),
                    new ShareGroupStatePartitionMetadataValue()
                        .setInitializedTopics(List.of(
                            new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                                .setTopicName(tName1)
                                .setTopicId(tid1)
                                .setPartitions(List.of(0, 1))
                        ))
                );
                context.commit();
                return new PendingAssignmentCase("Subscribed topics in metadata but assigned partitions differ.", groupId1, group, true, () -> {
                    verify(group).groupId();
                    verify(group).isEmpty();
                    verify(group).subscribedTopicNames();
                    verify(group).targetAssignment();
                });
            },
            (GroupMetadataManagerTestContext context) -> {
                ShareGroup group = mock(ShareGroup.class);
                when(group.groupId()).thenReturn(groupId1);
                when(group.isEmpty()).thenReturn(false);
                when(group.subscribedTopicNames()).thenReturn(Map.of(tName1, new SubscriptionCount(1, 1)));
                when(group.targetAssignment()).thenReturn(Map.of(
                    tName1, new Assignment(Map.of(tid1, Set.of(0, 1))),
                    tName2, new Assignment(Map.of(tid2, Set.of(0)))
                ));
                context.groupMetadataManager.replay(
                    new ShareGroupStatePartitionMetadataKey()
                        .setGroupId(groupId1),
                    new ShareGroupStatePartitionMetadataValue()
                        .setInitializedTopics(List.of(
                            new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                                .setTopicName(tName1)
                                .setTopicId(tid1)
                                .setPartitions(List.of(0, 1)),
                            new ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
                                .setTopicName(tName2)
                                .setTopicId(tid2)
                                .setPartitions(List.of(0))
                        ))
                );
                context.commit();
                return new PendingAssignmentCase("Subscribed topics in metadata but assigned has other topics too.", groupId1, group, false, () -> {
                    verify(group).groupId();
                    verify(group).isEmpty();
                    verify(group).subscribedTopicNames();
                    verify(group).targetAssignment();
                });
            }
        );
    }

    @SuppressWarnings("ClassEscapesDefinedScope")
    @ParameterizedTest
    @MethodSource("generatePendingAssignmentCases")
    public void testShareGroupPendingAssignments(Function<GroupMetadataManagerTestContext, PendingAssignmentCase> testCase) {
        MockPartitionAssignor assignor = new MockPartitionAssignor("simple");
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withShareGroupAssignor(assignor)
            .build();

        PendingAssignmentCase test = testCase.apply(context);
        assertEquals(test.expectedValue, context.groupMetadataManager.initializedAssignmentPending(test.group), test.description);
        test.assertions.run();
    }

    @Test
    public void testCanComputeNextTargetAssignmentWithNoPreviousAssignmentTimestamp() {
        // The next target assignment can always be computed when there is no previous assignment timestamp.
        assertTrue(GroupMetadataManager.canComputeNextTargetAssignment(0, 1000, 0));
    }

    @Test
    public void testCanComputeNextTargetAssignmentWithZeroAssignmentIntervalMs() {
        // The next target assignment can always be computed when the assignment interval is zero.
        assertTrue(GroupMetadataManager.canComputeNextTargetAssignment(1000000, 0, 0));
        assertTrue(GroupMetadataManager.canComputeNextTargetAssignment(1000000, 0, 999999));
        assertTrue(GroupMetadataManager.canComputeNextTargetAssignment(1000000, 0, 1000000));
    }

    @Test
    public void testCanComputeNextTargetAssignment() {
        // The next target assignment cannot be computed before the timestamp of the end of the previous assignment computation.
        assertFalse(GroupMetadataManager.canComputeNextTargetAssignment(1000000, 5000, 0));
        assertFalse(GroupMetadataManager.canComputeNextTargetAssignment(1000000, 5000, 999999));

        // The next target assignment cannot be computed before the assignment interval has elapsed.
        assertFalse(GroupMetadataManager.canComputeNextTargetAssignment(1000000, 5000, 1000000));
        assertFalse(GroupMetadataManager.canComputeNextTargetAssignment(1000000, 5000, 1004999));

        // The next target assignment can be computed after the assignment interval has elapsed.
        assertTrue(GroupMetadataManager.canComputeNextTargetAssignment(1000000, 5000, 1005000));
        assertTrue(GroupMetadataManager.canComputeNextTargetAssignment(1000000, 5000, 1007500));
    }

    @Test
    public void testConsumerGroupAssignmentInterval() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNMENT_INTERVAL_MS_CONFIG, 5000)
            .withConfig(GroupCoordinatorConfig.CONSUMER_GROUP_ASSIGNORS_CONFIG, List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Member 1 joins the group and gets an assignment immediately.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            ))
        )));
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result1 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of(fooTopicName))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ConsumerGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                    ))),
            result1.response()
        );

        ConsumerGroupMember expectedMember1 = new ConsumerGroupMember.Builder(memberId1)
            .setState(MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)), 2))
            .build();

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember1),
                GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 2, computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage)
                ))),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
                )),
                GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember1)
            ),
            result1.records()
        );

        // Wait until just before the expected delay.
        context.time.sleep(4995);

        // Member 2 joins the group and gets no assignment.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 3, 4, 5)
            ))
        )));
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result2 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setRebalanceTimeoutMs(5000)
                .setServerAssignor("range")
                .setSubscribedTopicNames(List.of(fooTopicName))
                .setTopicPartitions(List.of()));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ConsumerGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of())),
            result2.response()
        );

        ConsumerGroupMember expectedMember2 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(), 2))
            .build();

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newConsumerGroupMemberSubscriptionRecord(groupId, expectedMember2),
                GroupCoordinatorRecordHelpers.newConsumerGroupEpochRecord(groupId, 3, computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage)
                ))),
                GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember2)
            ),
            result2.records()
        );

        // Wait a little more. The next target assignment can be computed now.
        context.time.sleep(10);

        // The next target assignment is computed.
        CoordinatorResult<ConsumerGroupHeartbeatResponseData, CoordinatorRecord> result3 = context.consumerGroupHeartbeat(
            new ConsumerGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(2));

        assertResponseEquals(
            new ConsumerGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(3)
                .setHeartbeatIntervalMs(5000),
            result3.response()
        );

        ConsumerGroupMember expectedMember3 = new ConsumerGroupMember.Builder(memberId2)
            .setState(MemberState.UNRELEASED_PARTITIONS)
            .setMemberEpoch(3)
            .setPreviousMemberEpoch(2)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setRebalanceTimeoutMs(5000)
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setServerAssignorName("range")
            .setAssignedPartitions(toAssignmentWithEpochs(mkAssignment(), 2))
            .build();

        assertUnorderedRecordsEquals(
            List.of(
                List.of(
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
                        mkTopicAssignment(fooTopicId, 0, 1, 2)
                    )),
                    GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentRecord(groupId, memberId2, mkAssignment(
                        mkTopicAssignment(fooTopicId, 3, 4, 5)
                    ))
                ),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupTargetAssignmentMetadataRecord(groupId, 3, context.time.milliseconds())),
                List.of(GroupCoordinatorRecordHelpers.newConsumerGroupCurrentAssignmentRecord(groupId, expectedMember3))
            ),
            result3.records()
        );
    }

    @Test
    public void testShareGroupAssignmentInterval() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";

        MockPartitionAssignor assignor = new MockPartitionAssignor("range");

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.SHARE_GROUP_ASSIGNMENT_INTERVAL_MS_CONFIG, 5000)
            .withShareGroupAssignor(assignor)
            .withMetadataImage(metadataImage)
            .build();

        // Member 1 joins the group and gets an assignment immediately.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            ))
        )));
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result1 = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of(fooTopicName)));

        assertResponseEquals(
            new ShareGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ShareGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ShareGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                    ))),
            result1.response().getKey()
        );

        ShareGroupMember expectedMember1 = new ShareGroupMember.Builder(memberId1)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setAssignedPartitions(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
            .build();

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newShareGroupMemberSubscriptionRecord(groupId, expectedMember1),
                GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 2, computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage)
                ))),
                GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentRecord(groupId, memberId1, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
                )),
                GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),
                GroupCoordinatorRecordHelpers.newShareGroupCurrentAssignmentRecord(groupId, expectedMember1),
                GroupCoordinatorRecordHelpers.newShareGroupStatePartitionMetadataRecord(groupId, mkShareGroupStateMap(List.of(
                        mkShareGroupStateMetadataEntry(fooTopicId, fooTopicName, List.of(0, 1, 2, 3, 4, 5))
                    )),
                    Map.of(),
                    Map.of()
                )
            ),
            result1.records()
        );

        // Wait until just before the expected delay.
        context.time.sleep(4995);

        // Member 2 joins the group and gets no assignment.
        assignor.prepareGroupAssignment(new GroupAssignment(Map.of(
            memberId1, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            )),
            memberId2, new MemberAssignmentImpl(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
            ))
        )));
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result2 = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setSubscribedTopicNames(List.of(fooTopicName)));

        assertResponseEquals(
            new ShareGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ShareGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of())),
            result2.response().getKey()
        );

        ShareGroupMember expectedMember2 = new ShareGroupMember.Builder(memberId2)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setAssignedPartitions(mkAssignment())
            .build();

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newShareGroupMemberSubscriptionRecord(groupId, expectedMember2),
                GroupCoordinatorRecordHelpers.newShareGroupEpochRecord(groupId, 3, computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage)
                ))),
                GroupCoordinatorRecordHelpers.newShareGroupCurrentAssignmentRecord(groupId, expectedMember2)
            ),
            result2.records()
        );

        // Wait a little more. The next target assignment can be computed now.
        context.time.sleep(10);

        // The next target assignment is computed.
        CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData, Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result3 = context.shareGroupHeartbeat(
            new ShareGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(2));

        assertResponseEquals(
            new ShareGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(3)
                .setHeartbeatIntervalMs(5000)
                .setAssignment(new ShareGroupHeartbeatResponseData.Assignment()
                    .setTopicPartitions(List.of(
                        new ShareGroupHeartbeatResponseData.TopicPartitions()
                            .setTopicId(fooTopicId)
                            .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                    ))),
            result3.response().getKey()
        );

        ShareGroupMember expectedMember3 = new ShareGroupMember.Builder(memberId2)
            .setMemberEpoch(3)
            .setPreviousMemberEpoch(2)
            .setClientId(DEFAULT_CLIENT_ID)
            .setClientHost(DEFAULT_CLIENT_ADDRESS.toString())
            .setSubscribedTopicNames(List.of(fooTopicName))
            .setAssignedPartitions(mkAssignment(
                mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)))
            .build();

        assertRecordsEquals(
            List.of(
                GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentRecord(groupId, memberId2, mkAssignment(
                    mkTopicAssignment(fooTopicId, 0, 1, 2, 3, 4, 5)
                )),
                GroupCoordinatorRecordHelpers.newShareGroupTargetAssignmentMetadataRecord(groupId, 3, context.time.milliseconds()),
                GroupCoordinatorRecordHelpers.newShareGroupCurrentAssignmentRecord(groupId, expectedMember3)
            ),
            result3.records()
        );
    }

    @Test
    public void testStreamsGroupAssignmentInterval() {
        String groupId = "fooup";
        String memberId1 = Uuid.randomUuid().toString();
        String memberId2 = Uuid.randomUuid().toString();

        String subtopology = "subtopology";
        Uuid fooTopicId = Uuid.randomUuid();
        String fooTopicName = "foo";
        Topology topology = new Topology().setSubtopologies(List.of(
            new Subtopology().setSubtopologyId(subtopology).setSourceTopics(List.of(fooTopicName))
        ));

        MockTaskAssignor assignor = new MockTaskAssignor("sticky");

        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
            .addTopic(fooTopicId, fooTopicName, 6)
            .buildCoordinatorMetadataImage();

        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 0)
            .withConfig(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNMENT_INTERVAL_MS_CONFIG, 5000)
            .withStreamsGroupTaskAssignors(List.of(assignor))
            .withMetadataImage(metadataImage)
            .build();

        // Member 1 joins the group and gets an assignment immediately.
        assignor.prepareGroupAssignment(Map.of(memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
            TaskAssignmentTestUtil.mkTasks(subtopology, 0, 1, 2, 3, 4, 5)
        )));
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result1 = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId1)
                .setMemberEpoch(0)
                .setProcessId("process-id")
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId1)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of(
                    new StreamsGroupHeartbeatResponseData.TaskIds()
                        .setSubtopologyId(subtopology)
                        .setPartitions(List.of(0, 1, 2, 3, 4, 5))
                ))
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setStatus(List.of())
                .setTaskOffsetIntervalMs(0),
            result1.response().data()
        );

        StreamsGroupMember expectedMember1 = streamsGroupMemberBuilderWithDefaults(memberId1)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 2,
                TaskAssignmentTestUtil.mkTasks(subtopology, 0, 1, 2, 3, 4, 5)))
            .build();

        assertRecordsEquals(
            List.of(
                StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, expectedMember1),
                StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecord(groupId, topology),
                StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(groupId, 2, computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage)
                )), 0, Map.of("num.standby.replicas", "0")),
                StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId1,
                    TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                        TaskAssignmentTestUtil.mkTasks(subtopology, 0, 1, 2, 3, 4, 5)
                    )),
                StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 2, context.time.milliseconds()),
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember1)
            ),
            result1.records()
        );

        // Wait until just before the expected delay.
        context.time.sleep(4995);

        // Member 2 joins the group and gets no assignment.
        assignor.prepareGroupAssignment(Map.of(
            memberId1, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology, 0, 1, 2)
            ),
            memberId2, TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                TaskAssignmentTestUtil.mkTasks(subtopology, 3, 4, 5)
            )
        ));
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result2 = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(0)
                .setProcessId("process-id")
                .setRebalanceTimeoutMs(1500)
                .setTopology(topology)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of()));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(2)
                .setHeartbeatIntervalMs(5000)
                .setActiveTasks(List.of())
                .setStandbyTasks(List.of())
                .setWarmupTasks(List.of())
                .setEndpointInformationEpoch(0)
                .setStatus(List.of(new StreamsGroupHeartbeatResponseData.Status()
                    .setStatusCode(Status.ASSIGNMENT_DELAYED.code())
                    .setStatusDetail("Assignment delayed due to the configured assignment interval.")))
                .setTaskOffsetIntervalMs(0),
            result2.response().data()
        );

        StreamsGroupMember expectedMember2 = streamsGroupMemberBuilderWithDefaults(memberId2)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
            .setMemberEpoch(2)
            .setPreviousMemberEpoch(0)
            .build();

        assertRecordsEquals(
            List.of(
                StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId, expectedMember2),
                StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(groupId, 3, computeGroupHash(Map.of(
                    fooTopicName, computeTopicHash(fooTopicName, metadataImage)
                )), 0, Map.of("num.standby.replicas", "0")),
                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember2)
            ),
            result2.records()
        );

        // Wait a little more. The next target assignment can be computed now.
        context.time.sleep(10);

        // The next target assignment is computed.
        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result3 = context.streamsGroupHeartbeat(
            new StreamsGroupHeartbeatRequestData()
                .setGroupId(groupId)
                .setMemberId(memberId2)
                .setMemberEpoch(2));

        assertResponseEquals(
            new StreamsGroupHeartbeatResponseData()
                .setMemberId(memberId2)
                .setMemberEpoch(3)
                .setHeartbeatIntervalMs(5000)
                .setEndpointInformationEpoch(0)
                .setStatus(List.of())
                .setTaskOffsetIntervalMs(0),
            result3.response().data()
        );

        StreamsGroupMember expectedMember3 = streamsGroupMemberBuilderWithDefaults(memberId2)
            .setState(org.apache.kafka.coordinator.group.streams.MemberState.UNRELEASED_TASKS)
            .setMemberEpoch(3)
            .setPreviousMemberEpoch(2)
            .build();

        assertUnorderedRecordsEquals(
            List.of(
                List.of(
                    StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId1,
                        TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                            TaskAssignmentTestUtil.mkTasks(subtopology, 0, 1, 2)
                        )),
                    StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentRecord(groupId, memberId2,
                        TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
                            TaskAssignmentTestUtil.mkTasks(subtopology, 3, 4, 5)
                        ))
                ),
                List.of(StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId, 3, context.time.milliseconds())),
                List.of(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId, expectedMember3))
            ),
            result3.records()
        );
    }

    private static void checkJoinGroupResponse(
        JoinGroupResponseData expectedResponse,
        JoinGroupResponseData actualResponse,
        ClassicGroup group,
        ClassicGroupState expectedState,
        Set<String> expectedGroupInstanceIds
    ) {
        assertEquals(expectedResponse, actualResponse);
        assertTrue(group.isInState(expectedState));

        Set<String> groupInstanceIds = actualResponse.members()
                                                     .stream()
                                                     .map(JoinGroupResponseMember::groupInstanceId)
                                                     .collect(Collectors.toSet());

        assertEquals(expectedGroupInstanceIds, groupInstanceIds);
    }

    private static List<JoinGroupResponseMember> toJoinResponseMembers(ClassicGroup group) {
        List<JoinGroupResponseMember> members = new ArrayList<>();
        String protocolName = group.protocolName().get();
        group.allMembers().forEach(member -> members.add(
            new JoinGroupResponseMember()
                .setMemberId(member.memberId())
                .setGroupInstanceId(member.groupInstanceId().orElse(""))
                .setMetadata(member.metadata(protocolName))
        ));

        return members;
    }

    private static List<String> verifyClassicGroupJoinResponses(
        List<GroupMetadataManagerTestContext.JoinResult> joinResults,
        int expectedSuccessCount,
        Errors expectedFailure
    ) {
        int successCount = 0;
        List<String> memberIds = new ArrayList<>();
        for (GroupMetadataManagerTestContext.JoinResult joinResult : joinResults) {
            if (!joinResult.joinFuture.isDone()) {
                fail("All responseFutures should be completed.");
            }
            try {
                JoinGroupResponseData joinResponse = joinResult.joinFuture.get();
                if (joinResponse.errorCode() == Errors.NONE.code()) {
                    successCount++;
                } else {
                    assertEquals(expectedFailure.code(), joinResponse.errorCode());
                }
                memberIds.add(joinResponse.memberId());
            } catch (Exception e) {
                fail("Unexpected exception: " + e.getMessage());
            }
        }

        assertEquals(expectedSuccessCount, successCount);
        return memberIds;
    }

    private void verifyShareGroupHeartbeatInitializeRequest(
        Optional<InitializeShareGroupStateParameters> initRequest,
        Map<Uuid, Set<Integer>> expectedTopicPartitionsMap,
        String groupId,
        int stateEpoch,
        boolean shouldExist
    ) {
        if (shouldExist) {
            assertTrue(initRequest.isPresent());
            InitializeShareGroupStateParameters request = initRequest.get();
            assertEquals(groupId, request.groupTopicPartitionData().groupId());
            Map<Uuid, Set<Integer>> actualTopicPartitionsMap = new HashMap<>();
            for (TopicData<PartitionStateData> topicData : request.groupTopicPartitionData().topicsData()) {
                actualTopicPartitionsMap.computeIfAbsent(topicData.topicId(), k -> new HashSet<>())
                    .addAll(topicData.partitions().stream().map(partitionData -> {
                        assertEquals(stateEpoch, partitionData.stateEpoch());
                        assertEquals(-1, partitionData.startOffset());
                        return partitionData.partition();
                    }).toList());
            }
            assertEquals(expectedTopicPartitionsMap, actualTopicPartitionsMap);
        } else {
            assertTrue(initRequest.isEmpty());
        }
    }

    private void verifyShareGroupDeleteRequest(
        Optional<DeleteShareGroupStateParameters> deleteRequest,
        Map<Uuid, Set<Integer>> expectedTopicPartitionsMap,
        String groupId,
        boolean shouldExist
    ) {
        if (shouldExist) {
            assertTrue(deleteRequest.isPresent());
            DeleteShareGroupStateParameters request = deleteRequest.get();
            assertEquals(groupId, request.groupTopicPartitionData().groupId());
            Map<Uuid, Set<Integer>> actualTopicPartitionsMap = new HashMap<>();
            for (TopicData<PartitionIdData> topicData : request.groupTopicPartitionData().topicsData()) {
                actualTopicPartitionsMap.computeIfAbsent(topicData.topicId(), k -> new HashSet<>())
                    .addAll(topicData.partitions().stream().map(PartitionIdData::partition).toList());
            }
            assertEquals(expectedTopicPartitionsMap, actualTopicPartitionsMap);
        } else {
            assertTrue(deleteRequest.isEmpty());
        }
    }

    private Map<Uuid, DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> convertResponseTopicListToMap(
        List<DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic> responseTopics
    ) {
        return responseTopics.stream()
            .collect(Collectors.toMap(DeleteShareGroupOffsetsResponseData.DeleteShareGroupOffsetsResponseTopic::topicId, Function.identity()));
    }

    /**
     * Returns the default assignment configurations that would be used by the system.
     * This matches what streamsGroupAssignmentConfigs() would return.
     */
    private Map<String, String> getDefaultAssignmentConfigs() {
        // Use the same default value as GroupCoordinatorConfig.STREAMS_GROUP_NUM_STANDBY_REPLICAS_DEFAULT
        return new TreeMap<>(Map.of(
            "num.standby.replicas", String.valueOf(GroupCoordinatorConfig.STREAMS_GROUP_NUM_STANDBY_REPLICAS_DEFAULT)
        ));
    }
}
