// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.VectorData; using Microsoft.SemanticKernel.Connectors.MongoDB; using MongoDB.Bson; using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using Moq; using Xunit; using MEVD = Microsoft.Extensions.VectorData; namespace SemanticKernel.Connectors.MongoDB.UnitTests; /// /// Unit tests for class. /// public sealed class MongoCollectionTests { private readonly Mock _mockMongoDatabase = new(); private readonly Mock> _mockMongoCollection = new(); public MongoCollectionTests() { this._mockMongoDatabase .Setup(l => l.GetCollection(It.IsAny(), It.IsAny())) .Returns(this._mockMongoCollection.Object); } [Fact] public void ConstructorForModelWithoutKeyThrowsException() { // Act & Assert var exception = Assert.Throws(() => new MongoCollection(this._mockMongoDatabase.Object, "collection")); Assert.Contains("No key property found", exception.Message); } [Fact] public void ConstructorWithDeclarativeModelInitializesCollection() { // Act & Assert using var collection = new MongoCollection( this._mockMongoDatabase.Object, "collection"); Assert.NotNull(collection); } [Fact] public void ConstructorWithImperativeModelInitializesCollection() { // Arrange var definition = new VectorStoreCollectionDefinition { Properties = [new VectorStoreKeyProperty("Id", typeof(string))] }; // Act using var collection = new MongoCollection( this._mockMongoDatabase.Object, "collection", new() { Definition = definition }); // Assert Assert.NotNull(collection); } [Theory] [MemberData(nameof(CollectionExistsData))] public async Task CollectionExistsReturnsValidResultAsync(List collections, string collectionName, bool expectedResult) { // Arrange var mockCursor = new Mock>(); mockCursor .Setup(l => l.MoveNextAsync(It.IsAny())) .ReturnsAsync(true); mockCursor .Setup(l => l.Current) .Returns(collections); this._mockMongoDatabase .Setup(l => l.ListCollectionNamesAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(mockCursor.Object); using var sut = new MongoCollection( this._mockMongoDatabase.Object, collectionName); // Act var actualResult = await sut.CollectionExistsAsync(); // Assert Assert.Equal(expectedResult, actualResult); } [Fact] public async Task EnsureCollectionExistsInvokesValidMethodsAsync() { // Arrange const string CollectionName = "collection"; var mockCursor = new Mock>(); mockCursor .Setup(l => l.MoveNextAsync(It.IsAny())) .ReturnsAsync(true); mockCursor .Setup(l => l.Current) .Returns([]); this._mockMongoDatabase .Setup(l => l.ListCollectionNamesAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(mockCursor.Object); var mockIndexCursor = new Mock>(); mockIndexCursor .SetupSequence(l => l.MoveNext(It.IsAny())) .Returns(true) .Returns(false); mockIndexCursor .Setup(l => l.Current) .Returns([]); var mockMongoIndexManager = new Mock>(); mockMongoIndexManager .Setup(l => l.ListAsync(It.IsAny())) .ReturnsAsync(mockIndexCursor.Object); this._mockMongoCollection .Setup(l => l.Indexes) .Returns(mockMongoIndexManager.Object); using var sut = new MongoCollection( this._mockMongoDatabase.Object, CollectionName); // Act await sut.EnsureCollectionExistsAsync(); // Assert this._mockMongoDatabase.Verify(l => l.CreateCollectionAsync( CollectionName, It.IsAny(), It.IsAny()), Times.Exactly(1)); this._mockMongoDatabase.Verify(l => l.ListCollectionNamesAsync( It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task DeleteInvokesValidMethodsAsync() { // Arrange const string RecordKey = "key"; using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection"); var serializerRegistry = BsonSerializer.SerializerRegistry; var documentSerializer = serializerRegistry.GetSerializer(); var expectedDefinition = Builders.Filter.Eq(document => document["_id"], RecordKey); // Act await sut.DeleteAsync(RecordKey); // Assert this._mockMongoCollection.Verify(l => l.DeleteOneAsync( It.Is>(definition => CompareFilterDefinitions(definition, expectedDefinition, documentSerializer, serializerRegistry)), It.IsAny()), Times.Once()); } [Fact] public async Task DeleteBatchInvokesValidMethodsAsync() { // Arrange List recordKeys = ["key1", "key2"]; using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection"); var serializerRegistry = BsonSerializer.SerializerRegistry; var documentSerializer = serializerRegistry.GetSerializer(); var expectedDefinition = Builders.Filter.In(document => document["_id"].AsString, recordKeys); // Act await sut.DeleteAsync(recordKeys); // Assert this._mockMongoCollection.Verify(l => l.DeleteManyAsync( It.Is>(definition => CompareFilterDefinitions(definition, expectedDefinition, documentSerializer, serializerRegistry)), It.IsAny()), Times.Once()); } [Fact] public async Task DeleteCollectionInvokesValidMethodsAsync() { // Arrange const string CollectionName = "collection"; using var sut = new MongoCollection( this._mockMongoDatabase.Object, CollectionName); // Act await sut.EnsureCollectionDeletedAsync(); // Assert this._mockMongoDatabase.Verify(l => l.DropCollectionAsync( It.Is(name => name == CollectionName), It.IsAny()), Times.Once()); } [Fact] public async Task GetReturnsValidRecordAsync() { // Arrange const string RecordKey = "key"; var document = new BsonDocument { ["_id"] = RecordKey, ["HotelName"] = "Test Name" }; var mockCursor = new Mock>(); mockCursor .Setup(l => l.MoveNextAsync(It.IsAny())) .ReturnsAsync(true); mockCursor .Setup(l => l.Current) .Returns([document]); this._mockMongoCollection .Setup(l => l.FindAsync( It.IsAny>(), It.IsAny>(), It.IsAny())) .ReturnsAsync(mockCursor.Object); using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection"); // Act var result = await sut.GetAsync(RecordKey); // Assert Assert.NotNull(result); Assert.Equal(RecordKey, result.HotelId); Assert.Equal("Test Name", result.HotelName); } [Fact] public async Task GetBatchReturnsValidRecordAsync() { // Arrange var document1 = new BsonDocument { ["_id"] = "key1", ["HotelName"] = "Test Name 1" }; var document2 = new BsonDocument { ["_id"] = "key2", ["HotelName"] = "Test Name 2" }; var document3 = new BsonDocument { ["_id"] = "key3", ["HotelName"] = "Test Name 3" }; var mockCursor = new Mock>(); mockCursor .SetupSequence(l => l.MoveNextAsync(It.IsAny())) .ReturnsAsync(true) .ReturnsAsync(false); mockCursor .Setup(l => l.Current) .Returns([document1, document2, document3]); this._mockMongoCollection .Setup(l => l.FindAsync( It.IsAny>(), It.IsAny>(), It.IsAny())) .ReturnsAsync(mockCursor.Object); using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection"); // Act var results = await sut.GetAsync(["key1", "key2", "key3"]).ToListAsync(); // Assert Assert.NotNull(results[0]); Assert.Equal("key1", results[0].HotelId); Assert.Equal("Test Name 1", results[0].HotelName); Assert.NotNull(results[1]); Assert.Equal("key2", results[1].HotelId); Assert.Equal("Test Name 2", results[1].HotelName); Assert.NotNull(results[2]); Assert.Equal("key3", results[2].HotelId); Assert.Equal("Test Name 3", results[2].HotelName); } [Fact] public async Task CanUpsertRecordAsync() { // Arrange var hotel = new MongoHotelModel("key") { HotelName = "Test Name" }; var serializerRegistry = BsonSerializer.SerializerRegistry; var documentSerializer = serializerRegistry.GetSerializer(); var expectedDefinition = Builders.Filter.Eq(document => document["_id"], "key"); using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection"); // Act await sut.UpsertAsync(hotel); // Assert this._mockMongoCollection.Verify(l => l.ReplaceOneAsync( It.Is>(definition => CompareFilterDefinitions(definition, expectedDefinition, documentSerializer, serializerRegistry)), It.Is(document => document["_id"] == "key" && document["HotelName"] == "Test Name"), It.IsAny(), It.IsAny()), Times.Once()); } [Fact] public async Task CanUpsertManyRecordsAsync() { // Arrange var hotel1 = new MongoHotelModel("key1") { HotelName = "Test Name 1" }; var hotel2 = new MongoHotelModel("key2") { HotelName = "Test Name 2" }; var hotel3 = new MongoHotelModel("key3") { HotelName = "Test Name 3" }; using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection"); // Act await sut.UpsertAsync([hotel1, hotel2, hotel3]); } [Fact] public async Task UpsertWithModelWorksCorrectlyAsync() { var definition = new VectorStoreCollectionDefinition { Properties = [ new VectorStoreKeyProperty("Id", typeof(string)), new VectorStoreDataProperty("HotelName", typeof(string)) ] }; await this.TestUpsertWithModelAsync( dataModel: new TestModel { Id = "key", HotelName = "Test Name" }, expectedPropertyName: "HotelName", definition: definition); } [Fact] public async Task UpsertWithVectorStoreModelWorksCorrectlyAsync() { await this.TestUpsertWithModelAsync( dataModel: new VectorStoreTestModel { Id = "key", HotelName = "Test Name" }, expectedPropertyName: "HotelName"); } [Fact] public async Task UpsertWithBsonModelWorksCorrectlyAsync() { var definition = new VectorStoreCollectionDefinition { Properties = [ new VectorStoreKeyProperty("Id", typeof(string)), new VectorStoreDataProperty("HotelName", typeof(string)) ] }; await this.TestUpsertWithModelAsync( dataModel: new BsonTestModel { Id = "key", HotelName = "Test Name" }, expectedPropertyName: "hotel_name", definition: definition); } [Fact] public async Task UpsertWithBsonVectorStoreModelWorksCorrectlyAsync() { await this.TestUpsertWithModelAsync( dataModel: new BsonVectorStoreTestModel { Id = "key", HotelName = "Test Name" }, expectedPropertyName: "hotel_name"); } [Fact] public async Task UpsertWithBsonVectorStoreWithNameModelWorksCorrectlyAsync() { await this.TestUpsertWithModelAsync( dataModel: new BsonVectorStoreWithNameTestModel { Id = "key", HotelName = "Test Name" }, expectedPropertyName: "bson_hotel_name"); } [Theory] [MemberData(nameof(SearchVectorTypeData))] public async Task SearchThrowsExceptionWithInvalidVectorTypeAsync(object vector, bool exceptionExpected) { // Arrange this.MockCollectionForSearch(); using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection"); // Act & Assert if (exceptionExpected) { await Assert.ThrowsAsync(async () => await sut.SearchAsync(vector, top: 3).ToListAsync()); } else { Assert.NotNull(await sut.SearchAsync(vector, top: 3).FirstOrDefaultAsync()); } } [Theory] [InlineData("TestEmbedding1", "TestEmbedding1", 3, 3)] [InlineData("TestEmbedding2", "test_embedding_2", 4, 4)] public async Task SearchUsesValidQueryAsync( string? vectorPropertyName, string expectedVectorPropertyName, int actualTop, int expectedTop) { // Arrange var vector = new ReadOnlyMemory([1f, 2f, 3f]); var expectedSearch = new BsonDocument { { "$vectorSearch", new BsonDocument { { "index", "vector_index" }, { "queryVector", BsonArray.Create(vector.ToArray()) }, { "path", expectedVectorPropertyName }, { "limit", expectedTop }, { "numCandidates", expectedTop * 10 }, } } }; var expectedProjection = new BsonDocument { { "$project", new BsonDocument { { "similarityScore", new BsonDocument { { "$meta", "vectorSearchScore" } } }, { "document", "$$ROOT" } } } }; this.MockCollectionForSearch(); using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection"); Expression>? vectorSelector = vectorPropertyName switch { "TestEmbedding1" => record => record.TestEmbedding1, "TestEmbedding2" => record => record.TestEmbedding2, _ => null }; // Act var actual = await sut.SearchAsync(vector, top: actualTop, new() { VectorProperty = vectorSelector, }).FirstOrDefaultAsync(); // Assert Assert.NotNull(actual); this._mockMongoCollection.Verify(l => l.AggregateAsync( It.Is>(pipeline => this.ComparePipeline(pipeline, expectedSearch, expectedProjection)), It.IsAny(), It.IsAny()), Times.Once()); } [Fact] public async Task SearchThrowsExceptionWithNonExistentVectorPropertyNameAsync() { // Arrange this.MockCollectionForSearch(); using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection"); var options = new MEVD.VectorSearchOptions { VectorProperty = r => "non-existent-property" }; // Act & Assert await Assert.ThrowsAsync(async () => await sut.SearchAsync(new ReadOnlyMemory([1f, 2f, 3f]), top: 3, options).FirstOrDefaultAsync()); } [Fact] public async Task SearchReturnsRecordWithScoreAsync() { // Arrange this.MockCollectionForSearch(); using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection"); // Act var result = await sut.SearchAsync(new ReadOnlyMemory([1f, 2f, 3f]), top: 3).FirstOrDefaultAsync(); // Assert Assert.NotNull(result); Assert.Equal("key", result.Record.HotelId); Assert.Equal("Test Name", result.Record.HotelName); Assert.Equal(0.99f, result.Score); } public static TheoryData, string, bool> CollectionExistsData => new() { { ["collection-2"], "collection-2", true }, { [], "non-existent-collection", false } }; public static TheoryData, int> EnsureCollectionExistsData => new() { { ["collection"], 0 }, { [], 1 } }; public static TheoryData SearchVectorTypeData => new() { { new ReadOnlyMemory([1f, 2f, 3f]), false }, { new ReadOnlyMemory?(new([1f, 2f, 3f])), false }, { new List([1f, 2f, 3f]), true }, }; #region private private bool ComparePipeline( PipelineDefinition actualPipeline, BsonDocument expectedSearch, BsonDocument expectedProjection) { var serializerRegistry = BsonSerializer.SerializerRegistry; var documentSerializer = serializerRegistry.GetSerializer(); var documents = actualPipeline.Render(new RenderArgs(documentSerializer, serializerRegistry)).Documents; return documents[0].ToJson() == expectedSearch.ToJson() && documents[1].ToJson() == expectedProjection.ToJson(); } private void MockCollectionForSearch() { var document = new BsonDocument { ["_id"] = "key", ["HotelName"] = "Test Name" }; var searchResult = new BsonDocument { ["document"] = document, ["similarityScore"] = 0.99f }; var mockCursor = new Mock>(); mockCursor .Setup(l => l.MoveNextAsync(It.IsAny())) .ReturnsAsync(true); mockCursor .Setup(l => l.Current) .Returns([searchResult]); this._mockMongoCollection .Setup(l => l.AggregateAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(mockCursor.Object); } private async Task TestUpsertWithModelAsync( TDataModel dataModel, string expectedPropertyName, VectorStoreCollectionDefinition? definition = null) where TDataModel : class { // Arrange var serializerRegistry = BsonSerializer.SerializerRegistry; var documentSerializer = serializerRegistry.GetSerializer(); var expectedDefinition = Builders.Filter.Eq(document => document["_id"], "key"); MongoCollectionOptions? options = definition != null ? new() { Definition = definition } : null; using var sut = new MongoCollection( this._mockMongoDatabase.Object, "collection", options); // Act await sut.UpsertAsync(dataModel); // Assert this._mockMongoCollection.Verify(l => l.ReplaceOneAsync( It.Is>(definition => CompareFilterDefinitions(definition, expectedDefinition, documentSerializer, serializerRegistry)), It.Is(document => document["_id"] == "key" && document.Contains(expectedPropertyName) && document[expectedPropertyName] == "Test Name"), It.IsAny(), It.IsAny()), Times.Once()); } private static bool CompareFilterDefinitions( FilterDefinition actual, FilterDefinition expected, IBsonSerializer documentSerializer, IBsonSerializerRegistry serializerRegistry) { return actual.Render(new RenderArgs(documentSerializer, serializerRegistry)) == expected.Render(new RenderArgs(documentSerializer, serializerRegistry)); } #pragma warning disable CA1812 private sealed class TestModel { public string? Id { get; set; } public string? HotelName { get; set; } } private sealed class VectorStoreTestModel { [VectorStoreKey] public string? Id { get; set; } [VectorStoreData(StorageName = "hotel_name")] public string? HotelName { get; set; } } private sealed class BsonTestModel { [BsonId] public string? Id { get; set; } [BsonElement("hotel_name")] public string? HotelName { get; set; } } private sealed class BsonVectorStoreTestModel { [BsonId] [VectorStoreKey] public string? Id { get; set; } [BsonElement("hotel_name")] [VectorStoreData] public string? HotelName { get; set; } } private sealed class BsonVectorStoreWithNameTestModel { [BsonId] [VectorStoreKey] public string? Id { get; set; } [BsonElement("bson_hotel_name")] [VectorStoreData(StorageName = "storage_hotel_name")] public string? HotelName { get; set; } } private sealed class VectorSearchModel { [BsonId] [VectorStoreKey] public string? Id { get; set; } [VectorStoreData] public string? HotelName { get; set; } [VectorStoreVector(Dimensions: 4, DistanceFunction = DistanceFunction.CosineDistance, IndexKind = IndexKind.IvfFlat, StorageName = "test_embedding_1")] public ReadOnlyMemory TestEmbedding1 { get; set; } [BsonElement("test_embedding_2")] [VectorStoreVector(Dimensions: 4, DistanceFunction = DistanceFunction.CosineDistance, IndexKind = IndexKind.IvfFlat)] public ReadOnlyMemory TestEmbedding2 { get; set; } } #pragma warning restore CA1812 #endregion }