From 2f9745e7e93d1617be3250ce6c8301ca4e620339 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 4 Jun 2026 14:59:48 +0530 Subject: [PATCH 1/5] Add MCP tool integration tests with shared base class - Add McpToolTestBase with shared service provider builders and assertion helpers - Add ReadRecordsToolMsSqlIntegrationTests (filter, select, orderby, first, errors) - Add CreateRecordToolMsSqlIntegrationTests (valid data, missing fields, errors) - Add UpdateRecordToolMsSqlIntegrationTests (valid update, numeric fields, errors) - Add DeleteRecordToolMsSqlIntegrationTests (create-then-delete, verify-gone) - Add AggregateRecordsToolMsSqlIntegrationTests (count, sum/avg/min/max, groupby) - Refactor ExecuteEntityToolMsSqlIntegrationTests to use McpToolTestBase - Refactor DynamicCustomToolMsSqlIntegrationTests to use McpToolTestBase - Use DataTestMethod/DataRow to collapse similar test cases --- ...gregateRecordsToolMsSqlIntegrationTests.cs | 304 ++++++++++++++++++ .../CreateRecordToolMsSqlIntegrationTests.cs | 143 ++++++++ .../DeleteRecordToolMsSqlIntegrationTests.cs | 184 +++++++++++ .../DynamicCustomToolMsSqlIntegrationTests.cs | 93 +----- .../ExecuteEntityToolMsSqlIntegrationTests.cs | 134 +------- src/Service.Tests/Mcp/McpToolTestBase.cs | 228 +++++++++++++ .../ReadRecordsToolMsSqlIntegrationTests.cs | 191 +++++++++++ .../UpdateRecordToolMsSqlIntegrationTests.cs | 162 ++++++++++ 8 files changed, 1228 insertions(+), 211 deletions(-) create mode 100644 src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs create mode 100644 src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs create mode 100644 src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs create mode 100644 src/Service.Tests/Mcp/McpToolTestBase.cs create mode 100644 src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs create mode 100644 src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs diff --git a/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..5dc1384911 --- /dev/null +++ b/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs @@ -0,0 +1,304 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Integration tests for AggregateRecordsTool against a real MsSql database. + /// The books table has: id (int PK), title (varchar), publisher_id (int). + /// Seed data: 21 books with publisher_ids: 1234, 2345, 2323, 2324, 1940, 1941. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class AggregateRecordsToolMsSqlIntegrationTests : McpToolTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + #region COUNT Tests + + /// + /// Counts all records in the Book entity using COUNT(*). + /// + [TestMethod] + public async Task Aggregate_CountAll_ReturnsCorrectCount() + { + CallToolResult result = await ExecuteAggregateAsync("Book", "count", "*"); + + AssertSuccess(result, "COUNT(*) should succeed."); + + JsonElement root = ParseResultRoot(result); + Assert.AreEqual("Book", root.GetProperty("entity").GetString()); + Assert.IsTrue(root.TryGetProperty("count", out JsonElement countElement), + "Response should contain 'count' property for COUNT(*)."); + Assert.IsTrue(countElement.GetInt32() >= 21, + $"Expected at least 21 books (seed data), got {countElement.GetInt32()}."); + } + + /// + /// Counts records with an OData filter (publisher_id eq 1234). + /// + [TestMethod] + public async Task Aggregate_CountWithFilter_ReturnsFilteredCount() + { + CallToolResult result = await ExecuteAggregateAsync( + "Book", "count", "*", filter: "publisher_id eq 1234"); + + AssertSuccess(result, "COUNT with filter should succeed."); + + JsonElement root = ParseResultRoot(result); + Assert.IsTrue(root.TryGetProperty("count", out JsonElement countElement)); + Assert.IsTrue(countElement.GetInt32() >= 10, + $"Expected at least 10 books with publisher_id=1234, got {countElement.GetInt32()}."); + } + + /// + /// Counts distinct publisher_id values. + /// + [TestMethod] + public async Task Aggregate_CountDistinct_ReturnsDistinctCount() + { + CallToolResult result = await ExecuteAggregateAsync( + "Book", "count", "publisher_id", distinct: true); + + AssertSuccess(result, "COUNT DISTINCT should succeed."); + + JsonElement root = ParseResultRoot(result); + int count = 0; + if (root.TryGetProperty("count", out JsonElement countElement)) + { + count = countElement.GetInt32(); + } + else if (root.TryGetProperty("count_publisher_id", out JsonElement aliasElement)) + { + count = aliasElement.GetInt32(); + } + else if (root.TryGetProperty("result", out JsonElement resultElem)) + { + count = resultElem.GetInt32(); + } + + Assert.IsTrue(count >= 6, $"Expected at least 6 distinct publisher_ids, got {count}."); + } + + #endregion + + #region SUM/AVG/MIN/MAX Tests + + /// + /// Validates that numeric aggregation functions (sum, avg, min, max) succeed on publisher_id. + /// + [DataTestMethod] + [DataRow("sum", "sum_publisher_id", DisplayName = "SUM of publisher_id")] + [DataRow("avg", "avg_publisher_id", DisplayName = "AVG of publisher_id")] + [DataRow("min", "min_publisher_id", DisplayName = "MIN of publisher_id")] + [DataRow("max", "max_publisher_id", DisplayName = "MAX of publisher_id")] + public async Task Aggregate_NumericFunction_ReturnsResult(string function, string expectedAlias) + { + CallToolResult result = await ExecuteAggregateAsync("Book", function, "publisher_id"); + + AssertSuccess(result, $"{function.ToUpper()} should succeed on numeric field."); + + string content = GetFirstTextContent(result); + Assert.IsFalse(string.IsNullOrWhiteSpace(content), $"Expected non-empty result for {function.ToUpper()}."); + } + + /// + /// Validates that MIN returns the expected minimum value. + /// + [TestMethod] + public async Task Aggregate_Min_ReturnsExpectedMinValue() + { + CallToolResult result = await ExecuteAggregateAsync("Book", "min", "publisher_id"); + AssertSuccess(result, "MIN should succeed."); + + JsonElement root = ParseResultRoot(result); + if (root.TryGetProperty("min_publisher_id", out JsonElement minElement)) + { + Assert.AreEqual(1234, minElement.GetInt32(), + "MIN publisher_id should be 1234 (from seed data)."); + } + } + + /// + /// Validates that MAX returns the expected maximum value. + /// + [TestMethod] + public async Task Aggregate_Max_ReturnsExpectedMaxValue() + { + CallToolResult result = await ExecuteAggregateAsync("Book", "max", "publisher_id"); + AssertSuccess(result, "MAX should succeed."); + + JsonElement root = ParseResultRoot(result); + if (root.TryGetProperty("max_publisher_id", out JsonElement maxElement)) + { + Assert.AreEqual(2345, maxElement.GetInt32(), + "MAX publisher_id should be 2345 (from seed data)."); + } + } + + #endregion + + #region GROUP BY Tests + + /// + /// Groups by publisher_id and counts records per group. + /// + [TestMethod] + public async Task Aggregate_GroupByWithCount_ReturnsGroupedResults() + { + CallToolResult result = await ExecuteAggregateAsync( + "Book", "count", "*", groupby: new[] { "publisher_id" }); + + AssertSuccess(result, "COUNT with GROUP BY should succeed."); + + JsonElement root = ParseResultRoot(result); + if (root.TryGetProperty("items", out JsonElement itemsElement)) + { + Assert.AreEqual(JsonValueKind.Array, itemsElement.ValueKind); + Assert.IsTrue(itemsElement.GetArrayLength() > 1, "Expected multiple groups."); + } + else if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.ValueKind == JsonValueKind.Array) + { + Assert.IsTrue(resultElement.GetArrayLength() > 1, "Expected multiple groups."); + } + } + + /// + /// Groups by publisher_id with first parameter for pagination. + /// + [TestMethod] + public async Task Aggregate_GroupByWithFirst_ReturnsPaginatedResults() + { + CallToolResult result = await ExecuteAggregateAsync( + "Book", "count", "*", groupby: new[] { "publisher_id" }, first: 2); + + AssertSuccess(result, "COUNT with GROUP BY and first should succeed."); + + JsonElement root = ParseResultRoot(result); + if (root.TryGetProperty("items", out JsonElement itemsElement)) + { + Assert.IsTrue(itemsElement.GetArrayLength() <= 2, "Expected at most 2 items when first=2."); + Assert.IsTrue(root.TryGetProperty("hasNextPage", out _), "Paginated response should include hasNextPage."); + } + } + + #endregion + + #region Error Cases + + /// + /// Validates error scenarios for AggregateRecordsTool. + /// + [DataTestMethod] + [DataRow("NonExistentEntity", "count", "*", "NonExistentEntity", DisplayName = "Invalid entity")] + [DataRow("Book", "sum", "nonexistent_field", null, DisplayName = "Invalid field")] + public async Task Aggregate_ErrorScenarios(string entity, string function, string field, string? expectedSubstring) + { + CallToolResult result = await ExecuteAggregateAsync(entity, function, field); + + Assert.IsTrue(result.IsError == true, $"Aggregate({entity}, {function}, {field}) should fail."); + if (expectedSubstring != null) + { + StringAssert.Contains(GetFirstTextContent(result), expectedSubstring); + } + } + + /// + /// Attempts aggregation with no arguments. + /// + [TestMethod] + public async Task Aggregate_NoArguments_ReturnsError() + { + IServiceProvider serviceProvider = BuildQueryServiceProvider(); + AggregateRecordsTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + /// + /// Attempts aggregation with an invalid function name. + /// + [TestMethod] + public async Task Aggregate_InvalidFunction_ReturnsError() + { + CallToolResult result = await ExecuteAggregateAsync("Book", "invalid_func", "*"); + + AssertError(result); + } + + #endregion + + private static async Task ExecuteAggregateAsync( + string entity, + string function, + string field, + string? filter = null, + bool distinct = false, + string[]? groupby = null, + string? orderby = null, + int? first = null, + string? after = null) + { + IServiceProvider serviceProvider = BuildQueryServiceProvider(); + AggregateRecordsTool tool = new(); + + var args = new Dictionary + { + { "entity", entity }, + { "function", function }, + { "field", field } + }; + + if (filter != null) + { + args["filter"] = filter; + } + + if (distinct) + { + args["distinct"] = true; + } + + if (groupby != null) + { + args["groupby"] = groupby; + } + + if (orderby != null) + { + args["orderby"] = orderby; + } + + if (first != null) + { + args["first"] = first; + } + + if (after != null) + { + args["after"] = after; + } + + return await ExecuteToolAsync(tool, serviceProvider, args); + } + } +} diff --git a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..1c301e0cb4 --- /dev/null +++ b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Integration tests for CreateRecordTool against a real MsSql database. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class CreateRecordToolMsSqlIntegrationTests : McpToolTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// + /// Creates a new book record with valid data and verifies success. + /// + [TestMethod] + public async Task CreateRecord_ValidData_ReturnsSuccess() + { + var data = new Dictionary + { + { "title", "Integration Test Book" }, + { "publisher_id", 1234 } + }; + + CallToolResult result = await ExecuteCreateAsync("Book", data); + + AssertSuccess(result, "CreateRecord should succeed with valid data."); + + JsonElement root = ParseResultRoot(result); + Assert.AreEqual("Book", root.GetProperty("entity").GetString()); + Assert.IsTrue(root.GetProperty("message").GetString()!.Contains("Successfully created"), + "Response message should indicate success."); + } + + /// + /// Creates a record and verifies the returned record contains the inserted data. + /// + [TestMethod] + public async Task CreateRecord_ReturnsCreatedData() + { + var data = new Dictionary + { + { "title", "Verify Created Data" }, + { "publisher_id", 2345 } + }; + + CallToolResult result = await ExecuteCreateAsync("Book", data); + + AssertSuccess(result, "CreateRecord should succeed."); + + JsonElement root = ParseResultRoot(result); + + Assert.IsTrue(root.TryGetProperty("result", out JsonElement resultElement), + "Response should contain 'result' property."); + + if (resultElement.ValueKind == JsonValueKind.Object && resultElement.TryGetProperty("value", out JsonElement valueArray)) + { + Assert.AreEqual(JsonValueKind.Array, valueArray.ValueKind); + Assert.IsTrue(valueArray.GetArrayLength() > 0); + JsonElement created = valueArray[0]; + Assert.AreEqual("Verify Created Data", created.GetProperty("title").GetString()); + Assert.AreEqual(2345, created.GetProperty("publisher_id").GetInt32()); + Assert.IsTrue(created.TryGetProperty("id", out _), "Created record should have an auto-generated id."); + } + } + + /// + /// Validates error scenarios for CreateRecordTool. + /// + [DataTestMethod] + [DataRow("NonExistentEntity", "NonExistentEntity", DisplayName = "Invalid entity")] + public async Task CreateRecord_InvalidEntity_ReturnsError(string entity, string expectedSubstring) + { + var data = new Dictionary { { "name", "Test" } }; + + CallToolResult result = await ExecuteCreateAsync(entity, data); + + AssertError(result, expectedSubstring); + } + + /// + /// Attempts to create a record without providing any arguments. + /// + [TestMethod] + public async Task CreateRecord_NoArguments_ReturnsError() + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + CreateRecordTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + /// + /// Attempts to create a record with missing required NOT NULL field (title). + /// + [TestMethod] + public async Task CreateRecord_MissingRequiredField_ReturnsError() + { + var data = new Dictionary + { + { "publisher_id", 1234 } + }; + + CallToolResult result = await ExecuteCreateAsync("Book", data); + + AssertError(result, message: "CreateRecord should fail when missing required NOT NULL fields."); + } + + private static async Task ExecuteCreateAsync(string entity, Dictionary data) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + CreateRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", entity }, + { "data", data } + }; + + return await ExecuteToolAsync(tool, serviceProvider, args); + } + } +} diff --git a/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..d56f62e341 --- /dev/null +++ b/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Integration tests for DeleteRecordTool against a real MsSql database. + /// Tests that delete records first create a record to avoid interfering with seed data. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class DeleteRecordToolMsSqlIntegrationTests : McpToolTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// + /// Creates a book then deletes it, verifying the delete succeeds. + /// + [TestMethod] + public async Task DeleteRecord_ExistingRecord_ReturnsSuccess() + { + int createdId = await CreateBookForDeletion("Delete Me Book"); + + var keys = new Dictionary { { "id", createdId } }; + CallToolResult result = await ExecuteDeleteAsync("Book", keys); + + AssertSuccess(result, "DeleteRecord should succeed for existing record."); + Assert.IsFalse(string.IsNullOrWhiteSpace(GetFirstTextContent(result))); + } + + /// + /// Attempts to delete a record with a non-existent key, expecting an error. + /// + [TestMethod] + public async Task DeleteRecord_NonExistentKey_ReturnsError() + { + var keys = new Dictionary { { "id", 99999 } }; + + CallToolResult result = await ExecuteDeleteAsync("Book", keys); + + AssertError(result); + } + + /// + /// Attempts to delete from a non-existent entity, expecting an error. + /// + [TestMethod] + public async Task DeleteRecord_InvalidEntity_ReturnsError() + { + var keys = new Dictionary { { "id", 1 } }; + + CallToolResult result = await ExecuteDeleteAsync("NonExistentEntity", keys); + + AssertError(result, "NonExistentEntity"); + } + + /// + /// Attempts to delete with no arguments, expecting an error. + /// + [TestMethod] + public async Task DeleteRecord_NoArguments_ReturnsError() + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + DeleteRecordTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + /// + /// Attempts to delete with null key value, expecting an error. + /// + [TestMethod] + public async Task DeleteRecord_NullKeyValue_ReturnsError() + { + var args = new Dictionary + { + { "entity", "Book" }, + { "keys", new Dictionary { { "id", null } } } + }; + + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + DeleteRecordTool tool = new(); + + string argsJson = JsonSerializer.Serialize(args); + using JsonDocument arguments = JsonDocument.Parse(argsJson); + + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + /// + /// Verifies that after successful deletion, the record is no longer accessible. + /// + [TestMethod] + public async Task DeleteRecord_ThenRead_RecordNotFound() + { + int createdId = await CreateBookForDeletion("Delete Then Verify Book"); + + // Delete the book + var keys = new Dictionary { { "id", createdId } }; + CallToolResult deleteResult = await ExecuteDeleteAsync("Book", keys); + AssertSuccess(deleteResult, "Delete should succeed."); + + // Verify it's gone via read + IServiceProvider readProvider = BuildQueryServiceProvider(); + ReadRecordsTool readTool = new(); + + var readArgs = new Dictionary { { "entity", "Book" }, { "filter", $"id eq {createdId}" } }; + CallToolResult readResult = await ExecuteToolAsync(readTool, readProvider, readArgs); + + if (readResult.IsError != true) + { + JsonElement root = ParseResultRoot(readResult); + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.AreEqual(0, records.GetArrayLength(), + "Deleted record should not be found in subsequent read."); + } + } + + /// + /// Creates a book record using the CreateRecordTool and returns its ID. + /// + private static async Task CreateBookForDeletion(string title) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + CreateRecordTool createTool = new(); + + var args = new Dictionary + { + { "entity", "Book" }, + { "data", new Dictionary { { "title", title }, { "publisher_id", 1234 } } } + }; + + CallToolResult createResult = await ExecuteToolAsync(createTool, serviceProvider, args); + Assert.IsTrue(createResult.IsError != true, $"Setup: Failed to create book for deletion test. {GetFirstTextContent(createResult)}"); + + JsonElement root = ParseResultRoot(createResult); + if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.ValueKind == JsonValueKind.Object && + resultElement.TryGetProperty("value", out JsonElement valueArray) && + valueArray.ValueKind == JsonValueKind.Array && + valueArray.GetArrayLength() > 0) + { + return valueArray[0].GetProperty("id").GetInt32(); + } + + Assert.Fail("Could not extract ID from created book record."); + return -1; + } + + private static async Task ExecuteDeleteAsync(string entity, Dictionary keys) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + DeleteRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", entity }, + { "keys", keys } + }; + + return await ExecuteToolAsync(tool, serviceProvider, args); + } + } +} diff --git a/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs index 5744fb7c3a..75e079b058 100644 --- a/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs @@ -5,25 +5,16 @@ using System; using System.Collections.Generic; -using System.Security.Claims; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; -using Azure.DataApiBuilder.Core.Resolvers; -using Azure.DataApiBuilder.Core.Resolvers.Factories; -using Azure.DataApiBuilder.Core.Services.Cache; using Azure.DataApiBuilder.Mcp.Core; using Azure.DataApiBuilder.Service.Tests.SqlTests; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; -using Moq; -using ZiggyCreatures.Caching.Fusion; namespace Azure.DataApiBuilder.Service.Tests.Mcp { @@ -40,7 +31,7 @@ namespace Azure.DataApiBuilder.Service.Tests.Mcp /// - GetBooks -> SP get_books, zero params /// [TestClass, TestCategory(TestCategory.MSSQL)] - public class DynamicCustomToolMsSqlIntegrationTests : SqlTestBase + public class DynamicCustomToolMsSqlIntegrationTests : McpToolTestBase { [ClassInitialize] public static async Task SetupAsync(TestContext context) @@ -79,8 +70,6 @@ public async Task DynamicCustomTool_SuccessfulExecution(string entityName, strin /// /// Verify GetBook with id=1 returns a matching record through DynamicCustomTool. - /// Unlike SuccessfulExecution data rows (which validate response structure only), - /// this test validates the actual returned data content (id field in the result). /// [TestMethod] public async Task DynamicCustomTool_GetBookById_ReturnsMatchingRecord() @@ -115,10 +104,8 @@ public async Task DynamicCustomTool_InvalidParamName_ReturnsError(string entityN Dictionary parameters = new() { { paramName, paramValue } }; CallToolResult result = await ExecuteCustomToolAsync(entityName, parameters); - Assert.IsTrue(result.IsError == true, + AssertError(result, paramName, $"Custom tool should reject parameter '{paramName}' not in DB metadata for '{entityName}'."); - string content = GetFirstTextContent(result); - StringAssert.Contains(content, paramName); } #region Schema Alignment Integration Tests @@ -132,7 +119,7 @@ public async Task DynamicCustomTool_InvalidParamName_ReturnsError(string entityN [DataRow("InsertBook", "publisher_id", "integer", DisplayName = "int param maps to integer (multi-param SP)")] public void InitializeMetadata_SchemaReflectsDbParameterTypes(string entityName, string paramName, string expectedType) { - IServiceProvider serviceProvider = BuildServiceProvider(); + IServiceProvider serviceProvider = BuildQueryServiceProvider(); RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService(); Entity entity = configProvider.GetConfig().Entities[entityName]; @@ -153,7 +140,7 @@ public void InitializeMetadata_SchemaReflectsDbParameterTypes(string entityName, [TestMethod] public void InitializeMetadata_ZeroParamSP_HasEmptyProperties() { - IServiceProvider serviceProvider = BuildServiceProvider(); + IServiceProvider serviceProvider = BuildQueryServiceProvider(); RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService(); Entity entity = configProvider.GetConfig().Entities["GetBooks"]; @@ -179,7 +166,7 @@ public void InitializeMetadata_ZeroParamSP_HasEmptyProperties() [DataRow("InsertBook", "publisher_id", "1234", DisplayName = "publisher_id description includes default '1234'")] public void InitializeMetadata_DescriptionIncludesConfigDefaults(string entityName, string paramName, string expectedDefault) { - IServiceProvider serviceProvider = BuildServiceProvider(); + IServiceProvider serviceProvider = BuildQueryServiceProvider(); RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService(); Entity entity = configProvider.GetConfig().Entities[entityName]; @@ -200,9 +187,8 @@ public void InitializeMetadata_DescriptionIncludesConfigDefaults(string entityNa /// private static async Task ExecuteCustomToolAsync(string entityName, Dictionary? parameters) { - IServiceProvider serviceProvider = BuildServiceProvider(); + IServiceProvider serviceProvider = BuildQueryServiceProvider(); - // Resolve the entity config from the runtime config RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService(); RuntimeConfig config = configProvider.GetConfig(); Entity entity = config.Entities[entityName]; @@ -217,72 +203,5 @@ private static async Task ExecuteCustomToolAsync(string entityNa return await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); } - - /// - /// Builds a service provider wired to the shared fixture's real providers. - /// Uses the same pattern as ExecuteEntityToolMsSqlIntegrationTests. - /// - private static IServiceProvider BuildServiceProvider() - { - ServiceCollection services = new(); - - RuntimeConfigProvider configProvider = _application.Services.GetRequiredService(); - services.AddSingleton(configProvider); - - services.AddSingleton(_metadataProviderFactory.Object); - services.AddSingleton(_authorizationResolver); - - DefaultHttpContext httpContext = new(); - httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS; - ClaimsIdentity identity = new( - authenticationType: "TestAuth", - nameType: null, - roleType: AuthenticationOptions.ROLE_CLAIM_TYPE); - identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, AuthorizationResolver.ROLE_ANONYMOUS)); - httpContext.User = new ClaimsPrincipal(identity); - IHttpContextAccessor httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; - services.AddSingleton(httpContextAccessor); - - Mock cache = new(); - DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor); - - SqlQueryEngine queryEngine = new( - _queryManagerFactory.Object, - _metadataProviderFactory.Object, - httpContextAccessor, - _authorizationResolver, - _gqlFilterParser, - new Mock>().Object, - configProvider, - cacheService); - - Mock queryEngineFactory = new(); - queryEngineFactory - .Setup(f => f.GetQueryEngine(It.IsAny())) - .Returns(queryEngine); - services.AddSingleton(queryEngineFactory.Object); - - services.AddLogging(); - - return services.BuildServiceProvider(); - } - - private static string GetFirstTextContent(CallToolResult result) - { - if (result.Content is null || result.Content.Count == 0) - { - return string.Empty; - } - - return result.Content[0] is TextContentBlock textBlock - ? textBlock.Text ?? string.Empty - : string.Empty; - } - - private static void AssertSuccess(CallToolResult result, string message) - { - Assert.IsTrue(result.IsError != true, - $"{message} Content: {GetFirstTextContent(result)}"); - } } } diff --git a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs index 624990dc38..5ff377a191 100644 --- a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs @@ -5,43 +5,28 @@ using System; using System.Collections.Generic; -using System.Security.Claims; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; -using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Core.Authorization; -using Azure.DataApiBuilder.Core.Configurations; -using Azure.DataApiBuilder.Core.Resolvers; -using Azure.DataApiBuilder.Core.Resolvers.Factories; -using Azure.DataApiBuilder.Core.Services.Cache; using Azure.DataApiBuilder.Mcp.BuiltInTools; using Azure.DataApiBuilder.Service.Tests.SqlTests; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; -using Moq; -using ZiggyCreatures.Caching.Fusion; namespace Azure.DataApiBuilder.Service.Tests.Mcp { /// /// Integration tests for ExecuteEntityTool's parameter validation and default application. - /// Verifies the end-to-end behavior after the fix: - /// - Parameters are validated against StoredProcedureDefinition.Parameters (DB metadata), - /// not config-side parameters alone. - /// - Config defaults are applied from ParameterDefinition.HasConfigDefault/ConfigDefaultValue - /// for any parameter the user did not supply. + /// Verifies: + /// - Parameters validated against StoredProcedureDefinition.Parameters (DB metadata). + /// - Config defaults applied from ParameterDefinition.HasConfigDefault/ConfigDefaultValue. /// - /// Scenarios (reuse SPs already defined in DatabaseSchema-MsSql.sql / dab-config.MsSql.json): + /// Uses SPs defined in DatabaseSchema-MsSql.sql / dab-config.MsSql.json: /// - GetBook -> SP get_book_by_id(@id int), no config params. /// - InsertBook -> SP insert_book(@title, @publisher_id), config defaults applied. /// - GetBooks -> SP get_books, zero params. /// [TestClass, TestCategory(TestCategory.MSSQL)] - public class ExecuteEntityToolMsSqlIntegrationTests : SqlTestBase + public class ExecuteEntityToolMsSqlIntegrationTests : McpToolTestBase { [ClassInitialize] public static async Task SetupAsync(TestContext context) @@ -52,11 +37,6 @@ public static async Task SetupAsync(TestContext context) /// /// Data-driven test validating successful SP execution across multiple parameter scenarios. - /// Each row exercises a distinct code path in ExecuteEntityTool: - /// - DB-discovered param with no config entry (validates the fix for param validation). - /// - Config defaults applied when user omits params. - /// - User-supplied params override config defaults. - /// - Zero-param SP succeeds with no parameters. /// [DataTestMethod] [DataRow("GetBook", "{\"id\": 1}", DisplayName = "DB-discovered param accepted (no config entry)")] @@ -74,7 +54,6 @@ public async Task ExecuteEntity_SuccessfulExecution(string entityName, string? p AssertSuccess(result, $"execute_entity failed for entity '{entityName}' with params '{parametersJson}'."); - // Parse response and verify structure string content = GetFirstTextContent(result); Assert.IsFalse(string.IsNullOrWhiteSpace(content), $"Expected non-empty result for entity '{entityName}'."); @@ -86,7 +65,6 @@ public async Task ExecuteEntity_SuccessfulExecution(string entityName, string? p /// /// Verify that GetBook with id=1 returns the actual book record from the database. - /// This ensures the parameter value is correctly passed to the stored procedure. /// [TestMethod] public async Task ExecuteEntity_GetBookById_ReturnsMatchingRecord() @@ -99,12 +77,8 @@ public async Task ExecuteEntity_GetBookById_ReturnsMatchingRecord() using JsonDocument doc = JsonDocument.Parse(GetFirstTextContent(result)); JsonElement root = doc.RootElement; - // Verify the value property contains the SP result with at least one record with id=1. - // SqlResponseHelpers.OkResponse wraps results in { value: [...] }, and - // BuildExecuteSuccessResponse serializes that as-is into the "value" field. Assert.IsTrue(root.TryGetProperty("value", out JsonElement valueWrapper), "Response should contain 'value' property."); - // The value may be the wrapper object { "value": [...] } or directly an array. JsonElement records = valueWrapper.ValueKind == JsonValueKind.Object ? valueWrapper.GetProperty("value") : valueWrapper; @@ -115,8 +89,7 @@ public async Task ExecuteEntity_GetBookById_ReturnsMatchingRecord() } /// - /// Verify that InsertBook with no user params applies config defaults (title="randomX", publisher_id="1234"). - /// The SP inserts using those defaults. We verify the tool reports success (the SP executed without error). + /// Verify that InsertBook with no user params applies config defaults. /// [TestMethod] public async Task ExecuteEntity_InsertBookWithDefaults_ExecutesSuccessfully() @@ -132,7 +105,6 @@ public async Task ExecuteEntity_InsertBookWithDefaults_ExecutesSuccessfully() /// /// Reject a parameter name that does not exist in the DB metadata. - /// Validation against StoredProcedureDefinition.Parameters should catch this. /// [DataTestMethod] [DataRow("GetBook", "nonexistent_param", "value", DisplayName = "Rejects unknown param on single-param SP")] @@ -142,108 +114,22 @@ public async Task ExecuteEntity_InvalidParamName_ReturnsError(string entityName, Dictionary parameters = new() { { paramName, paramValue } }; CallToolResult result = await ExecuteEntityAsync(entityName, parameters); - Assert.IsTrue(result.IsError == true, + AssertError(result, paramName, $"execute_entity should reject parameter '{paramName}' not in DB metadata for '{entityName}'."); - string content = GetFirstTextContent(result); - StringAssert.Contains(content, paramName); } private static async Task ExecuteEntityAsync(string entityName, Dictionary? parameters) { - IServiceProvider serviceProvider = BuildExecuteEntityServiceProvider(); + IServiceProvider serviceProvider = BuildQueryServiceProvider(); ExecuteEntityTool tool = new(); - var args = new Dictionary { { "entity", entityName } }; + var args = new Dictionary { { "entity", entityName } }; if (parameters != null) { args["parameters"] = parameters; } - string argsJson = JsonSerializer.Serialize(args); - using JsonDocument arguments = JsonDocument.Parse(argsJson); - - return await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); - } - - /// - /// Builds a service provider that wires ExecuteEntityTool to the shared fixture's - /// real ISqlMetadataProvider, real IQueryEngine (SqlQueryEngine), and real - /// authorization resolver, with a DefaultHttpContext carrying the anonymous role header. - /// Uses the RuntimeConfigProvider from the WebApplicationFactory so that the datasource - /// name matches what the real MsSqlQueryExecutor was initialized with. - /// - private static IServiceProvider BuildExecuteEntityServiceProvider() - { - ServiceCollection services = new(); - - // Use the RuntimeConfigProvider from the WebApplicationFactory — this is the same - // provider that initialized _queryExecutor, so its DefaultDataSourceName matches - // the key in _queryExecutor.ConnectionStringBuilders. - RuntimeConfigProvider configProvider = _application.Services.GetRequiredService(); - services.AddSingleton(configProvider); - - // Real metadata-provider factory backed by the shared fixture's live provider. - services.AddSingleton(_metadataProviderFactory.Object); - - // Real authorization resolver wired by SqlTestBase against the live config + provider. - services.AddSingleton(_authorizationResolver); - - // Real HttpContext carrying the anonymous role header and a ClaimsPrincipal - // with the anonymous role claim so that AuthorizationResolver.IsValidRoleContext - // (which calls httpContext.User.IsInRole) returns true. - DefaultHttpContext httpContext = new(); - httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS; - ClaimsIdentity identity = new( - authenticationType: "TestAuth", - nameType: null, - roleType: AuthenticationOptions.ROLE_CLAIM_TYPE); - identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, AuthorizationResolver.ROLE_ANONYMOUS)); - httpContext.User = new ClaimsPrincipal(identity); - IHttpContextAccessor httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; - services.AddSingleton(httpContextAccessor); - - // Build a real SqlQueryEngine using the shared fixtures. - Mock cache = new(); - DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor); - - SqlQueryEngine queryEngine = new( - _queryManagerFactory.Object, - _metadataProviderFactory.Object, - httpContextAccessor, - _authorizationResolver, - _gqlFilterParser, - new Mock>().Object, - configProvider, - cacheService); - - // Wrap in a mock IQueryEngineFactory that returns the real engine. - Mock queryEngineFactory = new(); - queryEngineFactory - .Setup(f => f.GetQueryEngine(It.IsAny())) - .Returns(queryEngine); - services.AddSingleton(queryEngineFactory.Object); - - services.AddLogging(); - - return services.BuildServiceProvider(); - } - - private static string GetFirstTextContent(CallToolResult result) - { - if (result.Content is null || result.Content.Count == 0) - { - return string.Empty; - } - - return result.Content[0] is TextContentBlock textBlock - ? textBlock.Text ?? string.Empty - : string.Empty; - } - - private static void AssertSuccess(CallToolResult result, string message) - { - Assert.IsTrue(result.IsError != true, - $"{message} Content: {GetFirstTextContent(result)}"); + return await ExecuteToolAsync(tool, serviceProvider, args); } } } diff --git a/src/Service.Tests/Mcp/McpToolTestBase.cs b/src/Service.Tests/Mcp/McpToolTestBase.cs new file mode 100644 index 0000000000..d0e65279af --- /dev/null +++ b/src/Service.Tests/Mcp/McpToolTestBase.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Resolvers; +using Azure.DataApiBuilder.Core.Resolvers.Factories; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.Cache; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; +using Moq; +using ZiggyCreatures.Caching.Fusion; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Shared base class for MCP tool integration tests. + /// Provides common service provider builders and assertion helpers to avoid duplication. + /// + public abstract class McpToolTestBase : SqlTestBase + { + #region Service Provider Builders + + /// + /// Builds a service provider for read-only tools (ReadRecordsTool, AggregateRecordsTool). + /// Includes: RuntimeConfigProvider, IMetadataProviderFactory, IAuthorizationResolver, + /// IAuthorizationService, IHttpContextAccessor, IQueryEngineFactory, GQLFilterParser, + /// IAbstractQueryManagerFactory. + /// + protected static IServiceProvider BuildQueryServiceProvider() + { + ServiceCollection services = new(); + + RuntimeConfigProvider configProvider = _application.Services.GetRequiredService(); + services.AddSingleton(configProvider); + + services.AddSingleton(_metadataProviderFactory.Object); + services.AddSingleton(_authorizationResolver); + services.AddSingleton(_gqlFilterParser); + services.AddSingleton(_queryManagerFactory.Object); + + IHttpContextAccessor httpContextAccessor = CreateAnonymousHttpContextAccessor(); + services.AddSingleton(httpContextAccessor); + + Mock authorizationService = new(); + authorizationService + .Setup(a => a.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny>())) + .ReturnsAsync(AuthorizationResult.Success()); + services.AddSingleton(authorizationService.Object); + + Mock cache = new(); + DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor); + + SqlQueryEngine queryEngine = new( + _queryManagerFactory.Object, + _metadataProviderFactory.Object, + httpContextAccessor, + _authorizationResolver, + _gqlFilterParser, + new Mock>().Object, + configProvider, + cacheService); + + Mock queryEngineFactory = new(); + queryEngineFactory + .Setup(f => f.GetQueryEngine(It.IsAny())) + .Returns(queryEngine); + services.AddSingleton(queryEngineFactory.Object); + + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + /// + /// Builds a service provider for mutation tools (CreateRecordTool, UpdateRecordTool, DeleteRecordTool). + /// Includes: RuntimeConfigProvider, IMetadataProviderFactory, IAuthorizationResolver, + /// IHttpContextAccessor, IMutationEngineFactory, RequestValidator. + /// + protected static IServiceProvider BuildMutationServiceProvider() + { + ServiceCollection services = new(); + + RuntimeConfigProvider configProvider = _application.Services.GetRequiredService(); + services.AddSingleton(configProvider); + + services.AddSingleton(_metadataProviderFactory.Object); + services.AddSingleton(_authorizationResolver); + + IHttpContextAccessor httpContextAccessor = CreateAnonymousHttpContextAccessor(); + services.AddSingleton(httpContextAccessor); + + services.AddSingleton(new RequestValidator(_metadataProviderFactory.Object, configProvider)); + + Mock cache = new(); + DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor); + + SqlQueryEngine queryEngine = new( + _queryManagerFactory.Object, + _metadataProviderFactory.Object, + httpContextAccessor, + _authorizationResolver, + _gqlFilterParser, + new Mock>().Object, + configProvider, + cacheService); + + Mock queryEngineFactory = new(); + queryEngineFactory + .Setup(f => f.GetQueryEngine(It.IsAny())) + .Returns(queryEngine); + + SqlMutationEngine mutationEngine = new( + _queryManagerFactory.Object, + _metadataProviderFactory.Object, + queryEngineFactory.Object, + _authorizationResolver, + _gqlFilterParser, + httpContextAccessor, + configProvider); + + Mock mutationEngineFactory = new(); + mutationEngineFactory + .Setup(f => f.GetMutationEngine(It.IsAny())) + .Returns(mutationEngine); + services.AddSingleton(mutationEngineFactory.Object); + + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + #endregion + + #region Helpers + + /// + /// Creates an HttpContextAccessor with anonymous role claims for MCP tool testing. + /// + protected static IHttpContextAccessor CreateAnonymousHttpContextAccessor() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS; + ClaimsIdentity identity = new( + authenticationType: "TestAuth", + nameType: null, + roleType: AuthenticationOptions.ROLE_CLAIM_TYPE); + identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, AuthorizationResolver.ROLE_ANONYMOUS)); + httpContext.User = new ClaimsPrincipal(identity); + return new HttpContextAccessor { HttpContext = httpContext }; + } + + /// + /// Executes an MCP tool with the given JSON arguments and service provider. + /// + protected static async Task ExecuteToolAsync(IMcpTool tool, IServiceProvider serviceProvider, Dictionary args) + { + string argsJson = JsonSerializer.Serialize(args); + using JsonDocument arguments = JsonDocument.Parse(argsJson); + return await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + } + + /// + /// Extracts the text content from the first content block of a CallToolResult. + /// + protected static string GetFirstTextContent(CallToolResult result) + { + if (result.Content is null || result.Content.Count == 0) + { + return string.Empty; + } + + return result.Content[0] is TextContentBlock textBlock + ? textBlock.Text ?? string.Empty + : string.Empty; + } + + /// + /// Asserts that a CallToolResult is not an error. + /// + protected static void AssertSuccess(CallToolResult result, string message) + { + Assert.IsTrue(result.IsError != true, + $"{message} Content: {GetFirstTextContent(result)}"); + } + + /// + /// Asserts that a CallToolResult is an error and its content contains the expected substring. + /// + protected static void AssertError(CallToolResult result, string? expectedSubstring = null, string? message = null) + { + Assert.IsTrue(result.IsError == true, message ?? "Expected an error result."); + if (expectedSubstring != null) + { + StringAssert.Contains(GetFirstTextContent(result), expectedSubstring); + } + } + + /// + /// Parses the first text content of a result into a JsonElement root. + /// + protected static JsonElement ParseResultRoot(CallToolResult result) + { + string content = GetFirstTextContent(result); + Assert.IsFalse(string.IsNullOrWhiteSpace(content), "Expected non-empty result content."); + return JsonDocument.Parse(content).RootElement; + } + + #endregion + } +} diff --git a/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..c3d165edf9 --- /dev/null +++ b/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Integration tests for ReadRecordsTool against a real MsSql database. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class ReadRecordsToolMsSqlIntegrationTests : McpToolTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// + /// Reads all records from the Book entity without any filters. + /// + [TestMethod] + public async Task ReadRecords_AllBooks_ReturnsResults() + { + CallToolResult result = await ExecuteReadAsync("Book"); + + AssertSuccess(result, "ReadRecords for Book should succeed."); + + JsonElement root = ParseResultRoot(result); + Assert.AreEqual("Book", root.GetProperty("entity").GetString()); + + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.AreEqual(JsonValueKind.Array, records.ValueKind); + Assert.IsTrue(records.GetArrayLength() > 0, "Expected at least one book record."); + } + + /// + /// Reads records with a select clause to retrieve only specific fields. + /// + [TestMethod] + public async Task ReadRecords_WithSelect_ReturnsSelectedFields() + { + CallToolResult result = await ExecuteReadAsync("Book", select: "id,title"); + + AssertSuccess(result, "ReadRecords with select should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement firstRecord = root.GetProperty("result").GetProperty("value")[0]; + Assert.IsTrue(firstRecord.TryGetProperty("id", out _), "Expected 'id' field in result."); + Assert.IsTrue(firstRecord.TryGetProperty("title", out _), "Expected 'title' field in result."); + } + + /// + /// Reads records with an OData filter expression and verifies all results match. + /// + [TestMethod] + public async Task ReadRecords_WithFilter_ReturnsFilteredResults() + { + CallToolResult result = await ExecuteReadAsync("Book", filter: "publisher_id eq 1234"); + + AssertSuccess(result, "ReadRecords with filter should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.IsTrue(records.GetArrayLength() > 0, "Expected filtered results."); + + foreach (JsonElement record in records.EnumerateArray()) + { + Assert.AreEqual(1234, record.GetProperty("publisher_id").GetInt32(), + "All filtered records should have publisher_id = 1234."); + } + } + + /// + /// Reads records with orderby to verify sorting. + /// + [TestMethod] + public async Task ReadRecords_WithOrderBy_ReturnsSortedResults() + { + CallToolResult result = await ExecuteReadAsync("Book", select: "id,title", orderby: new[] { "id desc" }); + + AssertSuccess(result, "ReadRecords with orderby should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.IsTrue(records.GetArrayLength() > 1, "Expected multiple records for ordering test."); + + int previousId = int.MaxValue; + foreach (JsonElement record in records.EnumerateArray()) + { + int currentId = record.GetProperty("id").GetInt32(); + Assert.IsTrue(currentId <= previousId, $"Records should be in descending order. Got {currentId} after {previousId}."); + previousId = currentId; + } + } + + /// + /// Reads records with first parameter to limit page size. + /// + [TestMethod] + public async Task ReadRecords_WithFirst_ReturnsLimitedResults() + { + CallToolResult result = await ExecuteReadAsync("Book", first: 3); + + AssertSuccess(result, "ReadRecords with first should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.AreEqual(3, records.GetArrayLength(), "Expected exactly 3 records when first=3."); + } + + /// + /// Reads a single record by primary key filter. + /// + [TestMethod] + public async Task ReadRecords_FilterById_ReturnsSingleRecord() + { + CallToolResult result = await ExecuteReadAsync("Book", filter: "id eq 1"); + + AssertSuccess(result, "ReadRecords with id filter should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.AreEqual(1, records.GetArrayLength(), "Expected exactly one record with id=1."); + Assert.AreEqual(1, records[0].GetProperty("id").GetInt32()); + } + + /// + /// Reads records for a non-existent entity, expecting an error. + /// + [TestMethod] + public async Task ReadRecords_InvalidEntity_ReturnsError() + { + CallToolResult result = await ExecuteReadAsync("NonExistentEntity"); + + AssertError(result, "NonExistentEntity"); + } + + private static async Task ExecuteReadAsync( + string entity, + string? select = null, + string? filter = null, + string[]? orderby = null, + int? first = null, + string? after = null) + { + IServiceProvider serviceProvider = BuildQueryServiceProvider(); + ReadRecordsTool tool = new(); + + var args = new Dictionary { { "entity", entity } }; + + if (select != null) + { + args["select"] = select; + } + + if (filter != null) + { + args["filter"] = filter; + } + + if (orderby != null) + { + args["orderby"] = orderby; + } + + if (first != null) + { + args["first"] = first; + } + + if (after != null) + { + args["after"] = after; + } + + return await ExecuteToolAsync(tool, serviceProvider, args); + } + } +} diff --git a/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..c28f7f9858 --- /dev/null +++ b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Integration tests for UpdateRecordTool against a real MsSql database. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class UpdateRecordToolMsSqlIntegrationTests : McpToolTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// + /// Updates an existing book record (id=1) with new title and verifies success. + /// + [TestMethod] + public async Task UpdateRecord_ValidKeysAndFields_ReturnsSuccess() + { + var keys = new Dictionary { { "id", 1 } }; + var fields = new Dictionary { { "title", "Updated Title via MCP" } }; + + CallToolResult result = await ExecuteUpdateAsync("Book", keys, fields); + + AssertSuccess(result, "UpdateRecord should succeed for existing record."); + + JsonElement root = ParseResultRoot(result); + Assert.AreEqual("Book", root.GetProperty("entity").GetString()); + Assert.IsTrue(root.GetProperty("message").GetString()!.Contains("Successfully updated"), + "Response message should indicate success."); + + if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.TryGetProperty("title", out JsonElement titleElement)) + { + Assert.AreEqual("Updated Title via MCP", titleElement.GetString()); + } + } + + /// + /// Updates publisher_id field of an existing record and verifies the change. + /// + [TestMethod] + public async Task UpdateRecord_UpdateNumericField_ReturnsUpdatedValue() + { + var keys = new Dictionary { { "id", 2 } }; + var fields = new Dictionary { { "publisher_id", 9999 } }; + + CallToolResult result = await ExecuteUpdateAsync("Book", keys, fields); + + AssertSuccess(result, "UpdateRecord should succeed for numeric field update."); + + JsonElement root = ParseResultRoot(result); + if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.TryGetProperty("publisher_id", out JsonElement pubElement)) + { + Assert.AreEqual(9999, pubElement.GetInt32()); + } + } + + /// + /// Validates error scenarios for UpdateRecordTool. + /// + [DataTestMethod] + [DataRow(99999, "Book", DisplayName = "Non-existent key")] + public async Task UpdateRecord_NonExistentKey_ReturnsError(int keyId, string entity) + { + var keys = new Dictionary { { "id", keyId } }; + var fields = new Dictionary { { "title", "Ghost Book" } }; + + CallToolResult result = await ExecuteUpdateAsync(entity, keys, fields); + + AssertError(result); + } + + /// + /// Attempts to update a non-existent entity, expecting an error. + /// + [TestMethod] + public async Task UpdateRecord_InvalidEntity_ReturnsError() + { + var keys = new Dictionary { { "id", 1 } }; + var fields = new Dictionary { { "name", "Test" } }; + + CallToolResult result = await ExecuteUpdateAsync("NonExistentEntity", keys, fields); + + AssertError(result, "NonExistentEntity"); + } + + /// + /// Attempts to update with no arguments, expecting an error. + /// + [TestMethod] + public async Task UpdateRecord_NoArguments_ReturnsError() + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + UpdateRecordTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + /// + /// Attempts to update with null key value, expecting an error. + /// + [TestMethod] + public async Task UpdateRecord_NullKeyValue_ReturnsError() + { + var args = new Dictionary + { + { "entity", "Book" }, + { "keys", new Dictionary { { "id", null } } }, + { "fields", new Dictionary { { "title", "Test" } } } + }; + + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + UpdateRecordTool tool = new(); + + string argsJson = JsonSerializer.Serialize(args); + using JsonDocument arguments = JsonDocument.Parse(argsJson); + + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + private static async Task ExecuteUpdateAsync( + string entity, + Dictionary keys, + Dictionary fields) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + UpdateRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", entity }, + { "keys", keys }, + { "fields", fields } + }; + + return await ExecuteToolAsync(tool, serviceProvider, args); + } + } +} From f63e9f846b071da7b0dadb7f7af7de8eaab1b24a Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 4 Jun 2026 15:29:33 +0530 Subject: [PATCH 2/5] Format fixes --- .../Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs | 1 - src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs | 1 - src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs | 1 - src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs | 1 - src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs | 1 - src/Service.Tests/Mcp/McpToolTestBase.cs | 1 - src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs | 1 - src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs | 1 - 8 files changed, 8 deletions(-) diff --git a/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs index 5dc1384911..d69bf57577 100644 --- a/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs @@ -9,7 +9,6 @@ using System.Threading; using System.Threading.Tasks; using Azure.DataApiBuilder.Mcp.BuiltInTools; -using Azure.DataApiBuilder.Service.Tests.SqlTests; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; diff --git a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs index 1c301e0cb4..84b72318ea 100644 --- a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs @@ -9,7 +9,6 @@ using System.Threading; using System.Threading.Tasks; using Azure.DataApiBuilder.Mcp.BuiltInTools; -using Azure.DataApiBuilder.Service.Tests.SqlTests; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; diff --git a/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs index d56f62e341..5821e41133 100644 --- a/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs @@ -9,7 +9,6 @@ using System.Threading; using System.Threading.Tasks; using Azure.DataApiBuilder.Mcp.BuiltInTools; -using Azure.DataApiBuilder.Service.Tests.SqlTests; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; diff --git a/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs index 75e079b058..1e65159ea6 100644 --- a/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs @@ -11,7 +11,6 @@ using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Mcp.Core; -using Azure.DataApiBuilder.Service.Tests.SqlTests; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; diff --git a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs index 5ff377a191..76a664275b 100644 --- a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs @@ -8,7 +8,6 @@ using System.Text.Json; using System.Threading.Tasks; using Azure.DataApiBuilder.Mcp.BuiltInTools; -using Azure.DataApiBuilder.Service.Tests.SqlTests; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; diff --git a/src/Service.Tests/Mcp/McpToolTestBase.cs b/src/Service.Tests/Mcp/McpToolTestBase.cs index d0e65279af..09eb504900 100644 --- a/src/Service.Tests/Mcp/McpToolTestBase.cs +++ b/src/Service.Tests/Mcp/McpToolTestBase.cs @@ -16,7 +16,6 @@ using Azure.DataApiBuilder.Core.Resolvers.Factories; using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.Cache; -using Azure.DataApiBuilder.Mcp.BuiltInTools; using Azure.DataApiBuilder.Mcp.Model; using Azure.DataApiBuilder.Service.Tests.SqlTests; using Microsoft.AspNetCore.Authorization; diff --git a/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs index c3d165edf9..2691dcf604 100644 --- a/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs @@ -8,7 +8,6 @@ using System.Text.Json; using System.Threading.Tasks; using Azure.DataApiBuilder.Mcp.BuiltInTools; -using Azure.DataApiBuilder.Service.Tests.SqlTests; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; diff --git a/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs index c28f7f9858..09307d0fae 100644 --- a/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs @@ -9,7 +9,6 @@ using System.Threading; using System.Threading.Tasks; using Azure.DataApiBuilder.Mcp.BuiltInTools; -using Azure.DataApiBuilder.Service.Tests.SqlTests; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; From 93b0b4ce3d07fc59008d75ab7ff591923567ead3 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 4 Jun 2026 15:42:46 +0530 Subject: [PATCH 3/5] Address PR review comments: fix seed data mutation, assertion skipping, and aggregate response parsing --- ...gregateRecordsToolMsSqlIntegrationTests.cs | 82 ++++++++------ .../CreateRecordToolMsSqlIntegrationTests.cs | 2 - .../DeleteRecordToolMsSqlIntegrationTests.cs | 15 +-- .../DynamicCustomToolMsSqlIntegrationTests.cs | 2 - .../ExecuteEntityToolMsSqlIntegrationTests.cs | 2 - src/Service.Tests/Mcp/McpToolTestBase.cs | 2 - .../ReadRecordsToolMsSqlIntegrationTests.cs | 2 - .../UpdateRecordToolMsSqlIntegrationTests.cs | 107 ++++++++++++++---- 8 files changed, 137 insertions(+), 77 deletions(-) diff --git a/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs index d69bf57577..71ec37e0a3 100644 --- a/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Text.Json; @@ -43,10 +41,11 @@ public async Task Aggregate_CountAll_ReturnsCorrectCount() JsonElement root = ParseResultRoot(result); Assert.AreEqual("Book", root.GetProperty("entity").GetString()); - Assert.IsTrue(root.TryGetProperty("count", out JsonElement countElement), - "Response should contain 'count' property for COUNT(*)."); - Assert.IsTrue(countElement.GetInt32() >= 21, - $"Expected at least 21 books (seed data), got {countElement.GetInt32()}."); + JsonElement resultArray = root.GetProperty("result"); + Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind, "Simple aggregate result should be an array."); + int count = resultArray[0].GetProperty("count").GetInt32(); + Assert.IsTrue(count >= 21, + $"Expected at least 21 books (seed data), got {count}."); } /// @@ -61,9 +60,10 @@ public async Task Aggregate_CountWithFilter_ReturnsFilteredCount() AssertSuccess(result, "COUNT with filter should succeed."); JsonElement root = ParseResultRoot(result); - Assert.IsTrue(root.TryGetProperty("count", out JsonElement countElement)); - Assert.IsTrue(countElement.GetInt32() >= 10, - $"Expected at least 10 books with publisher_id=1234, got {countElement.GetInt32()}."); + JsonElement resultArray = root.GetProperty("result"); + int count = resultArray[0].GetProperty("count").GetInt32(); + Assert.IsTrue(count >= 10, + $"Expected at least 10 books with publisher_id=1234, got {count}."); } /// @@ -78,19 +78,20 @@ public async Task Aggregate_CountDistinct_ReturnsDistinctCount() AssertSuccess(result, "COUNT DISTINCT should succeed."); JsonElement root = ParseResultRoot(result); + JsonElement resultArray = root.GetProperty("result"); + Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind, "Result should be an array."); + Assert.IsTrue(resultArray.GetArrayLength() > 0, "Result array should not be empty."); + + JsonElement firstRow = resultArray[0]; int count = 0; - if (root.TryGetProperty("count", out JsonElement countElement)) + if (firstRow.TryGetProperty("count", out JsonElement countElement)) { count = countElement.GetInt32(); } - else if (root.TryGetProperty("count_publisher_id", out JsonElement aliasElement)) + else if (firstRow.TryGetProperty("count_publisher_id", out JsonElement aliasElement)) { count = aliasElement.GetInt32(); } - else if (root.TryGetProperty("result", out JsonElement resultElem)) - { - count = resultElem.GetInt32(); - } Assert.IsTrue(count >= 6, $"Expected at least 6 distinct publisher_ids, got {count}."); } @@ -127,11 +128,11 @@ public async Task Aggregate_Min_ReturnsExpectedMinValue() AssertSuccess(result, "MIN should succeed."); JsonElement root = ParseResultRoot(result); - if (root.TryGetProperty("min_publisher_id", out JsonElement minElement)) - { - Assert.AreEqual(1234, minElement.GetInt32(), - "MIN publisher_id should be 1234 (from seed data)."); - } + JsonElement resultArray = root.GetProperty("result"); + Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind); + Assert.IsTrue(resultArray.GetArrayLength() > 0, "Result array should not be empty."); + Assert.AreEqual(1234, resultArray[0].GetProperty("min_publisher_id").GetInt32(), + "MIN publisher_id should be 1234 (from seed data)."); } /// @@ -144,11 +145,11 @@ public async Task Aggregate_Max_ReturnsExpectedMaxValue() AssertSuccess(result, "MAX should succeed."); JsonElement root = ParseResultRoot(result); - if (root.TryGetProperty("max_publisher_id", out JsonElement maxElement)) - { - Assert.AreEqual(2345, maxElement.GetInt32(), - "MAX publisher_id should be 2345 (from seed data)."); - } + JsonElement resultArray = root.GetProperty("result"); + Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind); + Assert.IsTrue(resultArray.GetArrayLength() > 0, "Result array should not be empty."); + Assert.AreEqual(2345, resultArray[0].GetProperty("max_publisher_id").GetInt32(), + "MAX publisher_id should be 2345 (from seed data)."); } #endregion @@ -167,15 +168,21 @@ public async Task Aggregate_GroupByWithCount_ReturnsGroupedResults() AssertSuccess(result, "COUNT with GROUP BY should succeed."); JsonElement root = ParseResultRoot(result); - if (root.TryGetProperty("items", out JsonElement itemsElement)) + JsonElement resultElement = root.GetProperty("result"); + + // Non-paginated GROUP BY returns result as an array + if (resultElement.ValueKind == JsonValueKind.Array) + { + Assert.IsTrue(resultElement.GetArrayLength() > 1, "Expected multiple groups."); + } + else if (resultElement.ValueKind == JsonValueKind.Object && + resultElement.TryGetProperty("items", out JsonElement itemsElement)) { - Assert.AreEqual(JsonValueKind.Array, itemsElement.ValueKind); Assert.IsTrue(itemsElement.GetArrayLength() > 1, "Expected multiple groups."); } - else if (root.TryGetProperty("result", out JsonElement resultElement) && - resultElement.ValueKind == JsonValueKind.Array) + else { - Assert.IsTrue(resultElement.GetArrayLength() > 1, "Expected multiple groups."); + Assert.Fail("Unexpected result shape for GROUP BY response."); } } @@ -191,11 +198,16 @@ public async Task Aggregate_GroupByWithFirst_ReturnsPaginatedResults() AssertSuccess(result, "COUNT with GROUP BY and first should succeed."); JsonElement root = ParseResultRoot(result); - if (root.TryGetProperty("items", out JsonElement itemsElement)) - { - Assert.IsTrue(itemsElement.GetArrayLength() <= 2, "Expected at most 2 items when first=2."); - Assert.IsTrue(root.TryGetProperty("hasNextPage", out _), "Paginated response should include hasNextPage."); - } + JsonElement resultElement = root.GetProperty("result"); + + // Paginated GROUP BY returns result as { items: [...], endCursor, hasNextPage } + Assert.AreEqual(JsonValueKind.Object, resultElement.ValueKind, + "Paginated GROUP BY result should be an object."); + Assert.IsTrue(resultElement.TryGetProperty("items", out JsonElement itemsElement), + "Paginated response should contain 'items' property."); + Assert.IsTrue(itemsElement.GetArrayLength() <= 2, "Expected at most 2 items when first=2."); + Assert.IsTrue(resultElement.TryGetProperty("hasNextPage", out _), + "Paginated response should include hasNextPage."); } #endregion diff --git a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs index 84b72318ea..d6f2ed7d8c 100644 --- a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Text.Json; diff --git a/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs index 5821e41133..73ce685a68 100644 --- a/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Text.Json; @@ -126,13 +124,12 @@ public async Task DeleteRecord_ThenRead_RecordNotFound() var readArgs = new Dictionary { { "entity", "Book" }, { "filter", $"id eq {createdId}" } }; CallToolResult readResult = await ExecuteToolAsync(readTool, readProvider, readArgs); - if (readResult.IsError != true) - { - JsonElement root = ParseResultRoot(readResult); - JsonElement records = root.GetProperty("result").GetProperty("value"); - Assert.AreEqual(0, records.GetArrayLength(), - "Deleted record should not be found in subsequent read."); - } + Assert.IsTrue(readResult.IsError != true, "Follow-up read after delete should succeed."); + + JsonElement root = ParseResultRoot(readResult); + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.AreEqual(0, records.GetArrayLength(), + "Deleted record should not be found in subsequent read."); } /// diff --git a/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs index 1e65159ea6..9ebadeacdd 100644 --- a/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Text.Json; diff --git a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs index 76a664275b..f4089d8e19 100644 --- a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Text.Json; diff --git a/src/Service.Tests/Mcp/McpToolTestBase.cs b/src/Service.Tests/Mcp/McpToolTestBase.cs index 09eb504900..963d3ac165 100644 --- a/src/Service.Tests/Mcp/McpToolTestBase.cs +++ b/src/Service.Tests/Mcp/McpToolTestBase.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Security.Claims; diff --git a/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs index 2691dcf604..70e645d056 100644 --- a/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Text.Json; diff --git a/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs index 09307d0fae..a792d2655b 100644 --- a/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Text.Json; @@ -28,48 +26,64 @@ public static async Task SetupAsync(TestContext context) } /// - /// Updates an existing book record (id=1) with new title and verifies success. + /// Creates a dedicated record, updates its title, verifies success, then cleans up. /// [TestMethod] public async Task UpdateRecord_ValidKeysAndFields_ReturnsSuccess() { - var keys = new Dictionary { { "id", 1 } }; - var fields = new Dictionary { { "title", "Updated Title via MCP" } }; + int createdId = await CreateBookForUpdate("Book Before Update"); + try + { + var keys = new Dictionary { { "id", createdId } }; + var fields = new Dictionary { { "title", "Updated Title via MCP" } }; - CallToolResult result = await ExecuteUpdateAsync("Book", keys, fields); + CallToolResult result = await ExecuteUpdateAsync("Book", keys, fields); - AssertSuccess(result, "UpdateRecord should succeed for existing record."); + AssertSuccess(result, "UpdateRecord should succeed for existing record."); - JsonElement root = ParseResultRoot(result); - Assert.AreEqual("Book", root.GetProperty("entity").GetString()); - Assert.IsTrue(root.GetProperty("message").GetString()!.Contains("Successfully updated"), - "Response message should indicate success."); + JsonElement root = ParseResultRoot(result); + Assert.AreEqual("Book", root.GetProperty("entity").GetString()); + Assert.IsTrue(root.GetProperty("message").GetString()!.Contains("Successfully updated"), + "Response message should indicate success."); - if (root.TryGetProperty("result", out JsonElement resultElement) && - resultElement.TryGetProperty("title", out JsonElement titleElement)) + if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.TryGetProperty("title", out JsonElement titleElement)) + { + Assert.AreEqual("Updated Title via MCP", titleElement.GetString()); + } + } + finally { - Assert.AreEqual("Updated Title via MCP", titleElement.GetString()); + await DeleteBook(createdId); } } /// - /// Updates publisher_id field of an existing record and verifies the change. + /// Creates a dedicated record, updates its publisher_id, verifies the change, then cleans up. /// [TestMethod] public async Task UpdateRecord_UpdateNumericField_ReturnsUpdatedValue() { - var keys = new Dictionary { { "id", 2 } }; - var fields = new Dictionary { { "publisher_id", 9999 } }; + int createdId = await CreateBookForUpdate("Numeric Field Update Book"); + try + { + var keys = new Dictionary { { "id", createdId } }; + var fields = new Dictionary { { "publisher_id", 9999 } }; - CallToolResult result = await ExecuteUpdateAsync("Book", keys, fields); + CallToolResult result = await ExecuteUpdateAsync("Book", keys, fields); - AssertSuccess(result, "UpdateRecord should succeed for numeric field update."); + AssertSuccess(result, "UpdateRecord should succeed for numeric field update."); - JsonElement root = ParseResultRoot(result); - if (root.TryGetProperty("result", out JsonElement resultElement) && - resultElement.TryGetProperty("publisher_id", out JsonElement pubElement)) + JsonElement root = ParseResultRoot(result); + if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.TryGetProperty("publisher_id", out JsonElement pubElement)) + { + Assert.AreEqual(9999, pubElement.GetInt32()); + } + } + finally { - Assert.AreEqual(9999, pubElement.GetInt32()); + await DeleteBook(createdId); } } @@ -157,5 +171,52 @@ private static async Task ExecuteUpdateAsync( return await ExecuteToolAsync(tool, serviceProvider, args); } + + private static async Task CreateBookForUpdate(string title) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + CreateRecordTool createTool = new(); + + var args = new Dictionary + { + { "entity", "Book" }, + { "data", new Dictionary { { "title", title }, { "publisher_id", 1234 } } } + }; + + CallToolResult createResult = await ExecuteToolAsync(createTool, serviceProvider, args); + Assert.IsTrue(createResult.IsError != true, $"Setup: Failed to create book for update test. {GetFirstTextContent(createResult)}"); + + JsonElement root = ParseResultRoot(createResult); + if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.ValueKind == JsonValueKind.Object && + resultElement.TryGetProperty("value", out JsonElement valueArray) && + valueArray.ValueKind == JsonValueKind.Array && + valueArray.GetArrayLength() > 0) + { + return valueArray[0].GetProperty("id").GetInt32(); + } + + if (resultElement.ValueKind == JsonValueKind.Array && resultElement.GetArrayLength() > 0) + { + return resultElement[0].GetProperty("id").GetInt32(); + } + + Assert.Fail("Could not extract created book ID from response."); + return -1; + } + + private static async Task DeleteBook(int id) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + DeleteRecordTool deleteTool = new(); + + var args = new Dictionary + { + { "entity", "Book" }, + { "keys", new Dictionary { { "id", id } } } + }; + + await ExecuteToolAsync(deleteTool, serviceProvider, args); + } } } From e6e22a852b9a53c97937dc7f62ab46090dcaa32c Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 4 Jun 2026 15:43:42 +0530 Subject: [PATCH 4/5] Format fixes --- src/Service.Tests/Mcp/DynamicCustomToolTests.cs | 2 -- src/Service.Tests/Mcp/ExecuteEntityToolTests.cs | 2 -- src/Service.Tests/Mcp/McpToolRegistryTests.cs | 2 -- 3 files changed, 6 deletions(-) diff --git a/src/Service.Tests/Mcp/DynamicCustomToolTests.cs b/src/Service.Tests/Mcp/DynamicCustomToolTests.cs index 6509c64b0a..bb67aec5a0 100644 --- a/src/Service.Tests/Mcp/DynamicCustomToolTests.cs +++ b/src/Service.Tests/Mcp/DynamicCustomToolTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Linq; diff --git a/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs b/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs index 2410c1ab8b..4cc7b352fd 100644 --- a/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs +++ b/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Text.Json; diff --git a/src/Service.Tests/Mcp/McpToolRegistryTests.cs b/src/Service.Tests/Mcp/McpToolRegistryTests.cs index 14f966d81b..d8c5dc0b59 100644 --- a/src/Service.Tests/Mcp/McpToolRegistryTests.cs +++ b/src/Service.Tests/Mcp/McpToolRegistryTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Linq; From 17cfa730eccbfae117ed3c98e74569128c763f69 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 4 Jun 2026 16:36:49 +0530 Subject: [PATCH 5/5] Fix test failures: use valid FK value and handle response shape for filtered reads --- .../ReadRecordsToolMsSqlIntegrationTests.cs | 18 ++++++++++++------ .../UpdateRecordToolMsSqlIntegrationTests.cs | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs index 70e645d056..e5f9b6b481 100644 --- a/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs @@ -59,23 +59,26 @@ public async Task ReadRecords_WithSelect_ReturnsSelectedFields() } /// - /// Reads records with an OData filter expression and verifies all results match. + /// Reads records with an OData filter expression and verifies filtered results are returned. /// [TestMethod] public async Task ReadRecords_WithFilter_ReturnsFilteredResults() { - CallToolResult result = await ExecuteReadAsync("Book", filter: "publisher_id eq 1234"); + CallToolResult result = await ExecuteReadAsync("Book", filter: "id gt 5"); AssertSuccess(result, "ReadRecords with filter should succeed."); JsonElement root = ParseResultRoot(result); - JsonElement records = root.GetProperty("result").GetProperty("value"); + JsonElement resultElement = root.GetProperty("result"); + JsonElement records = resultElement.ValueKind == JsonValueKind.Object && resultElement.TryGetProperty("value", out JsonElement valueElement) + ? valueElement + : resultElement; Assert.IsTrue(records.GetArrayLength() > 0, "Expected filtered results."); foreach (JsonElement record in records.EnumerateArray()) { - Assert.AreEqual(1234, record.GetProperty("publisher_id").GetInt32(), - "All filtered records should have publisher_id = 1234."); + Assert.IsTrue(record.GetProperty("id").GetInt32() > 5, + "All filtered records should have id > 5."); } } @@ -128,7 +131,10 @@ public async Task ReadRecords_FilterById_ReturnsSingleRecord() AssertSuccess(result, "ReadRecords with id filter should succeed."); JsonElement root = ParseResultRoot(result); - JsonElement records = root.GetProperty("result").GetProperty("value"); + JsonElement resultElement = root.GetProperty("result"); + JsonElement records = resultElement.ValueKind == JsonValueKind.Object && resultElement.TryGetProperty("value", out JsonElement valueElement) + ? valueElement + : resultElement; Assert.AreEqual(1, records.GetArrayLength(), "Expected exactly one record with id=1."); Assert.AreEqual(1, records[0].GetProperty("id").GetInt32()); } diff --git a/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs index a792d2655b..9d1286a2c2 100644 --- a/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs @@ -68,7 +68,7 @@ public async Task UpdateRecord_UpdateNumericField_ReturnsUpdatedValue() try { var keys = new Dictionary { { "id", createdId } }; - var fields = new Dictionary { { "publisher_id", 9999 } }; + var fields = new Dictionary { { "publisher_id", 2345 } }; CallToolResult result = await ExecuteUpdateAsync("Book", keys, fields); @@ -78,7 +78,7 @@ public async Task UpdateRecord_UpdateNumericField_ReturnsUpdatedValue() if (root.TryGetProperty("result", out JsonElement resultElement) && resultElement.TryGetProperty("publisher_id", out JsonElement pubElement)) { - Assert.AreEqual(9999, pubElement.GetInt32()); + Assert.AreEqual(2345, pubElement.GetInt32()); } } finally