Add MockMoqtSession.

The goal here is to allow MoQT applications to write tests without making assumptions about what happens on the wire (currently, the only way to write a test for MoqtSession is to mock out the WebTransport session underneath).

PiperOrigin-RevId: 741196294
diff --git a/build/source_list.bzl b/build/source_list.bzl
index b6c75a5..68fbf12 100644
--- a/build/source_list.bzl
+++ b/build/source_list.bzl
@@ -1548,6 +1548,7 @@
     "quic/moqt/moqt_session_interface.h",
     "quic/moqt/moqt_subscribe_windows.h",
     "quic/moqt/moqt_track.h",
+    "quic/moqt/test_tools/mock_moqt_session.h",
     "quic/moqt/test_tools/moqt_framer_utils.h",
     "quic/moqt/test_tools/moqt_session_peer.h",
     "quic/moqt/test_tools/moqt_simulator_harness.h",
@@ -1585,6 +1586,8 @@
     "quic/moqt/moqt_subscribe_windows_test.cc",
     "quic/moqt/moqt_track.cc",
     "quic/moqt/moqt_track_test.cc",
+    "quic/moqt/test_tools/mock_moqt_session.cc",
+    "quic/moqt/test_tools/mock_moqt_session_test.cc",
     "quic/moqt/test_tools/moqt_framer_utils.cc",
     "quic/moqt/test_tools/moqt_simulator_harness.cc",
     "quic/moqt/tools/chat_client.cc",
diff --git a/build/source_list.gni b/build/source_list.gni
index d66f724..ccf396b 100644
--- a/build/source_list.gni
+++ b/build/source_list.gni
@@ -1552,6 +1552,7 @@
     "src/quiche/quic/moqt/moqt_session_interface.h",
     "src/quiche/quic/moqt/moqt_subscribe_windows.h",
     "src/quiche/quic/moqt/moqt_track.h",
+    "src/quiche/quic/moqt/test_tools/mock_moqt_session.h",
     "src/quiche/quic/moqt/test_tools/moqt_framer_utils.h",
     "src/quiche/quic/moqt/test_tools/moqt_session_peer.h",
     "src/quiche/quic/moqt/test_tools/moqt_simulator_harness.h",
@@ -1589,6 +1590,8 @@
     "src/quiche/quic/moqt/moqt_subscribe_windows_test.cc",
     "src/quiche/quic/moqt/moqt_track.cc",
     "src/quiche/quic/moqt/moqt_track_test.cc",
+    "src/quiche/quic/moqt/test_tools/mock_moqt_session.cc",
+    "src/quiche/quic/moqt/test_tools/mock_moqt_session_test.cc",
     "src/quiche/quic/moqt/test_tools/moqt_framer_utils.cc",
     "src/quiche/quic/moqt/test_tools/moqt_simulator_harness.cc",
     "src/quiche/quic/moqt/tools/chat_client.cc",
diff --git a/build/source_list.json b/build/source_list.json
index 8ae8fe1..d721fe7 100644
--- a/build/source_list.json
+++ b/build/source_list.json
@@ -1551,6 +1551,7 @@
     "quiche/quic/moqt/moqt_session_interface.h",
     "quiche/quic/moqt/moqt_subscribe_windows.h",
     "quiche/quic/moqt/moqt_track.h",
+    "quiche/quic/moqt/test_tools/mock_moqt_session.h",
     "quiche/quic/moqt/test_tools/moqt_framer_utils.h",
     "quiche/quic/moqt/test_tools/moqt_session_peer.h",
     "quiche/quic/moqt/test_tools/moqt_simulator_harness.h",
@@ -1588,6 +1589,8 @@
     "quiche/quic/moqt/moqt_subscribe_windows_test.cc",
     "quiche/quic/moqt/moqt_track.cc",
     "quiche/quic/moqt/moqt_track_test.cc",
+    "quiche/quic/moqt/test_tools/mock_moqt_session.cc",
+    "quiche/quic/moqt/test_tools/mock_moqt_session_test.cc",
     "quiche/quic/moqt/test_tools/moqt_framer_utils.cc",
     "quiche/quic/moqt/test_tools/moqt_simulator_harness.cc",
     "quiche/quic/moqt/tools/chat_client.cc",
diff --git a/quiche/quic/moqt/test_tools/mock_moqt_session.cc b/quiche/quic/moqt/test_tools/mock_moqt_session.cc
new file mode 100644
index 0000000..cfd5b47
--- /dev/null
+++ b/quiche/quic/moqt/test_tools/mock_moqt_session.cc
@@ -0,0 +1,224 @@
+// Copyright 2025 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "quiche/quic/moqt/test_tools/mock_moqt_session.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "quiche/quic/moqt/moqt_failed_fetch.h"
+#include "quiche/quic/moqt/moqt_messages.h"
+#include "quiche/quic/moqt/moqt_priority.h"
+#include "quiche/quic/moqt/moqt_publisher.h"
+#include "quiche/quic/moqt/moqt_subscribe_windows.h"
+#include "quiche/quic/moqt/moqt_track.h"
+#include "quiche/common/platform/api/quiche_logging.h"
+#include "quiche/common/platform/api/quiche_test.h"
+#include "quiche/web_transport/web_transport.h"
+
+namespace moqt::test {
+
+namespace {
+using ::testing::_;
+}
+
+// Object listener that forwards all of the objects to the
+// SubcribeRemoteTrack::Visitor provided.
+class MockMoqtSession::LoopbackObjectListener : public MoqtObjectListener {
+ public:
+  LoopbackObjectListener(FullTrackName name,
+                         SubscribeRemoteTrack::Visitor* visitor,
+                         std::shared_ptr<MoqtTrackPublisher> publisher,
+                         SubscribeWindow window)
+      : name_(name),
+        visitor_(visitor),
+        publisher_(std::move(publisher)),
+        window_(std::move(window)) {
+    publisher_->AddObjectListener(this);
+  }
+  ~LoopbackObjectListener() { publisher_->RemoveObjectListener(this); }
+
+  LoopbackObjectListener(const LoopbackObjectListener&) = delete;
+  LoopbackObjectListener(LoopbackObjectListener&&) = delete;
+  LoopbackObjectListener& operator=(const LoopbackObjectListener&) = delete;
+  LoopbackObjectListener& operator=(LoopbackObjectListener&&) = delete;
+
+  void OnSubscribeAccepted() override {
+    visitor_->OnReply(name_,
+                      HasObjects()
+                          ? std::make_optional(publisher_->GetLargestSequence())
+                          : std::nullopt,
+                      std::nullopt);
+  }
+
+  void OnSubscribeRejected(MoqtSubscribeErrorReason reason,
+                           std::optional<uint64_t> track_alias) {
+    visitor_->OnReply(name_, std::nullopt, reason.reason_phrase);
+  }
+
+  void OnNewObjectAvailable(FullSequence sequence) {
+    std::optional<PublishedObject> object =
+        publisher_->GetCachedObject(sequence);
+    if (!object.has_value()) {
+      QUICHE_LOG(FATAL)
+          << "GetCachedObject() returned nullopt for a sequence passed into "
+             "OnNewObjectAvailable()";
+      return;
+    }
+    if (!window_.InWindow(object->sequence)) {
+      return;
+    }
+    visitor_->OnObjectFragment(name_, sequence, object->publisher_priority,
+                               object->status, object->payload.AsStringView(),
+                               /*end_of_message=*/true);
+  }
+
+  void OnNewFinAvailable(FullSequence sequence) override {}
+  void OnSubgroupAbandoned(FullSequence sequence,
+                           webtransport::StreamErrorCode error_code) override {}
+  void OnGroupAbandoned(uint64_t group_id) override {}
+  void OnTrackPublisherGone() override { visitor_->OnSubscribeDone(name_); }
+
+ private:
+  bool HasObjects() {
+    absl::StatusOr<MoqtTrackStatusCode> status = publisher_->GetTrackStatus();
+    if (!status.ok()) {
+      return false;
+    }
+    return *status == MoqtTrackStatusCode::kInProgress ||
+           *status == MoqtTrackStatusCode::kFinished;
+  }
+
+  FullTrackName name_;
+  SubscribeRemoteTrack::Visitor* visitor_;
+  std::shared_ptr<MoqtTrackPublisher> publisher_;
+  SubscribeWindow window_;
+};
+
+bool MockMoqtSession::Subscribe(const FullTrackName& name,
+                                SubscribeRemoteTrack::Visitor* visitor,
+                                SubscribeWindow window) {
+  auto track_publisher = publisher_->GetTrack(name);
+  if (!track_publisher.ok()) {
+    visitor->OnReply(name, std::nullopt, track_publisher.status().ToString());
+    return false;
+  }
+  auto [it, inserted] = receiving_subscriptions_.insert(
+      {name,
+       std::make_unique<LoopbackObjectListener>(
+           name, visitor, *std::move(track_publisher), std::move(window))});
+  return inserted;
+}
+
+MockMoqtSession::MockMoqtSession(MoqtPublisher* publisher)
+    : publisher_(publisher) {
+  ON_CALL(*this, Error)
+      .WillByDefault([](MoqtError code, absl::string_view error) {
+        ADD_FAILURE() << "Unhandled MoQT fatal error, with code "
+                      << static_cast<int>(code) << " and message: " << error;
+      });
+  if (publisher_ != nullptr) {
+    ON_CALL(*this, SubscribeCurrentObject)
+        .WillByDefault([this](const FullTrackName& name,
+                              SubscribeRemoteTrack::Visitor* visitor,
+                              MoqtSubscribeParameters) {
+          return Subscribe(name, visitor, SubscribeWindow(0, 0));
+        });
+    ON_CALL(*this, SubscribeAbsolute(_, _, _, _, _))
+        .WillByDefault([this](const FullTrackName& name, uint64_t start_group,
+                              uint64_t start_object,
+                              SubscribeRemoteTrack::Visitor* visitor,
+                              MoqtSubscribeParameters) {
+          return Subscribe(name, visitor,
+                           SubscribeWindow(start_group, start_object));
+        });
+    ON_CALL(*this, SubscribeAbsolute(_, _, _, _, _, _))
+        .WillByDefault([this](const FullTrackName& name, uint64_t start_group,
+                              uint64_t start_object, uint64_t end_group,
+                              SubscribeRemoteTrack::Visitor* visitor,
+                              MoqtSubscribeParameters) {
+          return Subscribe(name, visitor,
+                           SubscribeWindow(start_group, start_object, end_group,
+                                           UINT64_MAX));
+        });
+    ON_CALL(*this, Unsubscribe)
+        .WillByDefault([this](const FullTrackName& name) {
+          receiving_subscriptions_.erase(name);
+        });
+    ON_CALL(*this, Fetch)
+        .WillByDefault(
+            [this](const FullTrackName& name, FetchResponseCallback callback,
+                   FullSequence start, uint64_t end_group,
+                   std::optional<uint64_t> end_object, MoqtPriority priority,
+                   std::optional<MoqtDeliveryOrder> delivery_order,
+                   MoqtSubscribeParameters parameters) {
+              auto track_publisher = publisher_->GetTrack(name);
+              if (!track_publisher.ok()) {
+                std::move(callback)(std::make_unique<MoqtFailedFetch>(
+                    track_publisher.status()));
+                return true;
+              }
+              std::move(callback)(track_publisher->get()->Fetch(
+                  start, end_group, end_object,
+                  delivery_order.value_or(MoqtDeliveryOrder::kAscending)));
+              return true;
+            });
+    ON_CALL(*this, JoiningFetch(_, _, _, _))
+        .WillByDefault([this](const FullTrackName& name,
+                              SubscribeRemoteTrack::Visitor* visitor,
+                              uint64_t num_previous_groups,
+                              MoqtSubscribeParameters parameters) {
+          return JoiningFetch(
+              name, visitor,
+              [name, visitor](std::unique_ptr<MoqtFetchTask> fetch) {
+                PublishedObject object;
+                while (fetch->GetNextObject(object) ==
+                       MoqtFetchTask::kSuccess) {
+                  visitor->OnObjectFragment(
+                      name, object.sequence, object.publisher_priority,
+                      object.status, object.payload.AsStringView(), true);
+                }
+              },
+              num_previous_groups, 0x80, std::nullopt, parameters);
+        });
+    ON_CALL(*this, JoiningFetch(_, _, _, _, _, _, _))
+        .WillByDefault([this](const FullTrackName& name,
+                              SubscribeRemoteTrack::Visitor* visitor,
+                              FetchResponseCallback callback,
+                              uint64_t num_previous_groups,
+                              MoqtPriority priority,
+                              std::optional<MoqtDeliveryOrder> delivery_order,
+                              MoqtSubscribeParameters parameters) {
+          SubscribeCurrentObject(name, visitor, parameters);
+          auto track_publisher = publisher_->GetTrack(name);
+          if (!track_publisher.ok()) {
+            std::move(callback)(
+                std::make_unique<MoqtFailedFetch>(track_publisher.status()));
+            return true;
+          }
+          if (track_publisher->get()->GetTrackStatus().value_or(
+                  MoqtTrackStatusCode::kStatusNotAvailable) ==
+              MoqtTrackStatusCode::kNotYetBegun) {
+            return Fetch(name, std::move(callback), FullSequence(0, 0), 0, 0,
+                         priority, delivery_order, std::move(parameters));
+          }
+          FullSequence largest = track_publisher->get()->GetLargestSequence();
+          uint64_t start_group = largest.group >= num_previous_groups
+                                     ? largest.group - num_previous_groups + 1
+                                     : 0;
+          return Fetch(name, std::move(callback), FullSequence(start_group, 0),
+                       largest.group, largest.object, priority, delivery_order,
+                       std::move(parameters));
+        });
+  }
+}
+
+MockMoqtSession::~MockMoqtSession() = default;
+
+}  // namespace moqt::test
diff --git a/quiche/quic/moqt/test_tools/mock_moqt_session.h b/quiche/quic/moqt/test_tools/mock_moqt_session.h
new file mode 100644
index 0000000..d6eddb8
--- /dev/null
+++ b/quiche/quic/moqt/test_tools/mock_moqt_session.h
@@ -0,0 +1,102 @@
+// Copyright 2025 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef QUICHE_QUIC_MOQT_TEST_TOOLS_MOCK_MOQT_SESSION_H_
+#define QUICHE_QUIC_MOQT_TEST_TOOLS_MOCK_MOQT_SESSION_H_
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/strings/string_view.h"
+#include "quiche/quic/moqt/moqt_messages.h"
+#include "quiche/quic/moqt/moqt_priority.h"
+#include "quiche/quic/moqt/moqt_publisher.h"
+#include "quiche/quic/moqt/moqt_session_callbacks.h"
+#include "quiche/quic/moqt/moqt_session_interface.h"
+#include "quiche/quic/moqt/moqt_subscribe_windows.h"
+#include "quiche/quic/moqt/moqt_track.h"
+#include "quiche/common/platform/api/quiche_logging.h"
+#include "quiche/common/platform/api/quiche_test.h"
+
+namespace moqt::test {
+
+// Mock version of MoqtSession.  If `publisher` is provided via constructor, all
+// of the SUBSCRIBE and FETCH requests are routed towards it.
+class MockMoqtSession : public MoqtSessionInterface {
+ public:
+  explicit MockMoqtSession(MoqtPublisher* publisher);
+  ~MockMoqtSession() override;
+
+  MockMoqtSession(const MockMoqtSession&) = delete;
+  MockMoqtSession(MockMoqtSession&&) = delete;
+  MockMoqtSession& operator=(const MockMoqtSession&) = delete;
+  MockMoqtSession& operator=(MockMoqtSession&&) = delete;
+
+  MoqtSessionCallbacks& callbacks() override { return callbacks_; }
+
+  MOCK_METHOD(void, Error, (MoqtError code, absl::string_view error),
+              (override));
+  MOCK_METHOD(bool, SubscribeAbsolute,
+              (const FullTrackName& name, uint64_t start_group,
+               uint64_t start_object, SubscribeRemoteTrack::Visitor* visitor,
+               MoqtSubscribeParameters parameters),
+              (override));
+  MOCK_METHOD(bool, SubscribeAbsolute,
+              (const FullTrackName& name, uint64_t start_group,
+               uint64_t start_object, uint64_t end_group,
+               SubscribeRemoteTrack::Visitor* visitor,
+               MoqtSubscribeParameters parameters),
+              (override));
+  MOCK_METHOD(bool, SubscribeCurrentObject,
+              (const FullTrackName& name,
+               SubscribeRemoteTrack::Visitor* visitor,
+               MoqtSubscribeParameters parameters),
+              (override));
+  MOCK_METHOD(void, Unsubscribe, (const FullTrackName& name), (override));
+  MOCK_METHOD(bool, Fetch,
+              (const FullTrackName& name, FetchResponseCallback callback,
+               FullSequence start, uint64_t end_group,
+               std::optional<uint64_t> end_object, MoqtPriority priority,
+               std::optional<MoqtDeliveryOrder> delivery_order,
+               MoqtSubscribeParameters parameters),
+              (override));
+  MOCK_METHOD(bool, JoiningFetch,
+              (const FullTrackName& name,
+               SubscribeRemoteTrack::Visitor* visitor,
+               uint64_t num_previous_groups,
+               MoqtSubscribeParameters parameters),
+              (override));
+  MOCK_METHOD(bool, JoiningFetch,
+              (const FullTrackName& name,
+               SubscribeRemoteTrack::Visitor* visitor,
+               FetchResponseCallback callback, uint64_t num_previous_groups,
+               MoqtPriority priority,
+               std::optional<MoqtDeliveryOrder> delivery_order,
+               MoqtSubscribeParameters parameters),
+              (override));
+
+  [[deprecated]] bool SubscribeCurrentGroup(
+      const FullTrackName& name, SubscribeRemoteTrack::Visitor* visitor,
+      MoqtSubscribeParameters parameters) override {
+    QUICHE_LOG(FATAL) << "Don't call this";
+  }
+
+ private:
+  class LoopbackObjectListener;
+
+  bool Subscribe(const FullTrackName& name,
+                 SubscribeRemoteTrack::Visitor* visitor,
+                 SubscribeWindow window);
+
+  MoqtPublisher* const publisher_ = nullptr;
+  MoqtSessionCallbacks callbacks_;
+  absl::flat_hash_map<FullTrackName, std::unique_ptr<LoopbackObjectListener>>
+      receiving_subscriptions_;
+};
+
+}  // namespace moqt::test
+
+#endif  // QUICHE_QUIC_MOQT_TEST_TOOLS_MOCK_MOQT_SESSION_H_
diff --git a/quiche/quic/moqt/test_tools/mock_moqt_session_test.cc b/quiche/quic/moqt/test_tools/mock_moqt_session_test.cc
new file mode 100644
index 0000000..1ef2167
--- /dev/null
+++ b/quiche/quic/moqt/test_tools/mock_moqt_session_test.cc
@@ -0,0 +1,135 @@
+// Copyright 2025 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "quiche/quic/moqt/test_tools/mock_moqt_session.h"
+
+#include <memory>
+#include <optional>
+#include <utility>
+
+#include "quiche/quic/moqt/moqt_known_track_publisher.h"
+#include "quiche/quic/moqt/moqt_messages.h"
+#include "quiche/quic/moqt/moqt_outgoing_queue.h"
+#include "quiche/quic/moqt/moqt_publisher.h"
+#include "quiche/quic/moqt/tools/moqt_mock_visitor.h"
+#include "quiche/quic/test_tools/quic_test_utils.h"
+#include "quiche/common/platform/api/quiche_test.h"
+
+namespace moqt::test {
+namespace {
+
+using ::testing::_;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+using ::testing::Optional;
+
+FullTrackName TrackName() { return FullTrackName("foo", "bar"); }
+
+class MockMoqtSessionTest : public quiche::test::QuicheTest {
+ protected:
+  MockMoqtSessionTest() : session_(&publisher_) {
+    track_ = std::make_shared<MoqtOutgoingQueue>(
+        TrackName(), MoqtForwardingPreference::kSubgroup);
+    publisher_.Add(track_);
+  }
+
+  MoqtKnownTrackPublisher publisher_;
+  std::shared_ptr<MoqtOutgoingQueue> track_;
+  MockMoqtSession session_;
+};
+
+TEST_F(MockMoqtSessionTest, MissingTrack) {
+  testing::StrictMock<MockSubscribeRemoteTrackVisitor> visitor;
+  EXPECT_CALL(visitor,
+              OnReply(FullTrackName("doesn't", "exist"), Eq(std::nullopt),
+                      Optional(HasSubstr("not found"))));
+  session_.SubscribeCurrentObject(FullTrackName("doesn't", "exist"), &visitor,
+                                  MoqtSubscribeParameters());
+}
+
+TEST_F(MockMoqtSessionTest, SubscribeCurrentObject) {
+  testing::StrictMock<MockSubscribeRemoteTrackVisitor> visitor;
+  EXPECT_CALL(visitor,
+              OnReply(TrackName(), Eq(std::nullopt), Eq(std::nullopt)));
+  session_.SubscribeCurrentObject(TrackName(), &visitor,
+                                  MoqtSubscribeParameters());
+  EXPECT_CALL(visitor, OnObjectFragment(TrackName(), FullSequence(0, 0), _, _,
+                                        "test", _));
+  track_->AddObject(quic::test::MemSliceFromString("test"), /*key=*/true);
+
+  session_.Unsubscribe(TrackName());
+  track_->AddObject(quic::test::MemSliceFromString("test2"), /*key=*/true);
+  // No visitor call here.
+}
+
+TEST_F(MockMoqtSessionTest, SubscribeAbsolute) {
+  testing::StrictMock<MockSubscribeRemoteTrackVisitor> visitor;
+  EXPECT_CALL(visitor,
+              OnReply(TrackName(), Eq(std::nullopt), Eq(std::nullopt)));
+  session_.SubscribeAbsolute(TrackName(), 1, 0, 1, &visitor,
+                             MoqtSubscribeParameters());
+  EXPECT_CALL(visitor, OnObjectFragment(TrackName(), FullSequence(1, 0), _,
+                                        MoqtObjectStatus::kNormal, "b", _));
+  EXPECT_CALL(visitor, OnObjectFragment(TrackName(), FullSequence(1, 1), _,
+                                        MoqtObjectStatus::kEndOfGroup, "", _));
+  track_->AddObject(quic::test::MemSliceFromString("a"), /*key=*/true);
+  track_->AddObject(quic::test::MemSliceFromString("b"), /*key=*/true);
+  track_->AddObject(quic::test::MemSliceFromString("c"), /*key=*/true);
+}
+
+TEST_F(MockMoqtSessionTest, Fetch) {
+  track_->AddObject(quic::test::MemSliceFromString("a"), /*key=*/true);
+  track_->AddObject(quic::test::MemSliceFromString("b"), /*key=*/false);
+  track_->AddObject(quic::test::MemSliceFromString("c"), /*key=*/false);
+  track_->AddObject(quic::test::MemSliceFromString("d"), /*key=*/true);
+  std::unique_ptr<MoqtFetchTask> fetch;
+  session_.Fetch(
+      TrackName(),
+      [&](std::unique_ptr<MoqtFetchTask> new_fetch) {
+        fetch = std::move(new_fetch);
+      },
+      FullSequence(0, 1), 0, 2, 0x80, std::nullopt, MoqtSubscribeParameters());
+  PublishedObject object;
+  ASSERT_EQ(fetch->GetNextObject(object), MoqtFetchTask::kSuccess);
+  EXPECT_EQ(object.payload.AsStringView(), "b");
+  ASSERT_EQ(fetch->GetNextObject(object), MoqtFetchTask::kSuccess);
+  EXPECT_EQ(object.payload.AsStringView(), "c");
+  ASSERT_EQ(fetch->GetNextObject(object), MoqtFetchTask::kEof);
+}
+
+TEST_F(MockMoqtSessionTest, JoiningFetch) {
+  track_->AddObject(quic::test::MemSliceFromString("a"), /*key=*/true);
+  track_->AddObject(quic::test::MemSliceFromString("b"), /*key=*/true);
+  track_->AddObject(quic::test::MemSliceFromString("c"), /*key=*/true);
+  track_->AddObject(quic::test::MemSliceFromString("d"), /*key=*/true);
+
+  testing::StrictMock<MockSubscribeRemoteTrackVisitor> visitor;
+  EXPECT_CALL(visitor,
+              OnReply(TrackName(), Eq(FullSequence(3, 0)), Eq(std::nullopt)));
+  EXPECT_CALL(visitor, OnObjectFragment(TrackName(), FullSequence(2, 0), _,
+                                        MoqtObjectStatus::kNormal, "c", _));
+  EXPECT_CALL(visitor, OnObjectFragment(TrackName(), FullSequence(2, 1), _,
+                                        MoqtObjectStatus::kEndOfGroup, "", _));
+  EXPECT_CALL(visitor, OnObjectFragment(TrackName(), FullSequence(3, 0), _,
+                                        MoqtObjectStatus::kNormal, "d", _));
+  session_.JoiningFetch(TrackName(), &visitor, 2, MoqtSubscribeParameters());
+  EXPECT_CALL(visitor, OnObjectFragment(TrackName(), FullSequence(3, 1), _,
+                                        MoqtObjectStatus::kEndOfGroup, "", _));
+  EXPECT_CALL(visitor, OnObjectFragment(TrackName(), FullSequence(4, 0), _,
+                                        MoqtObjectStatus::kNormal, "e", _));
+  track_->AddObject(quic::test::MemSliceFromString("e"), /*key=*/true);
+}
+
+TEST_F(MockMoqtSessionTest, JoiningFetchNoObjects) {
+  testing::StrictMock<MockSubscribeRemoteTrackVisitor> visitor;
+  EXPECT_CALL(visitor,
+              OnReply(TrackName(), Eq(std::nullopt), Eq(std::nullopt)));
+  session_.JoiningFetch(TrackName(), &visitor, 0, MoqtSubscribeParameters());
+  EXPECT_CALL(visitor, OnObjectFragment(TrackName(), FullSequence(0, 0), _, _,
+                                        "test", _));
+  track_->AddObject(quic::test::MemSliceFromString("test"), /*key=*/true);
+}
+
+}  // namespace
+}  // namespace moqt::test