db620d33df
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
459 lines
17 KiB
C#
459 lines
17 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Agents.AI.Workflows;
|
|
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
|
using Microsoft.Azure.Cosmos;
|
|
|
|
namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
|
|
|
|
/// <summary>
|
|
/// Contains tests for <see cref="CosmosCheckpointStore"/>.
|
|
///
|
|
/// Test Modes:
|
|
/// - Default Mode: Cleans up all test data after each test run (deletes database)
|
|
/// - Preserve Mode: Keeps containers and data for inspection in Cosmos DB Emulator Data Explorer
|
|
///
|
|
/// To enable Preserve Mode, set environment variable: COSMOSDB_PRESERVE_CONTAINERS=true
|
|
/// Example: $env:COSMOSDB_PRESERVE_CONTAINERS="true"; dotnet test
|
|
///
|
|
/// In Preserve Mode, you can view the data in Cosmos DB Emulator Data Explorer at:
|
|
/// https://localhost:8081/_explorer/index.html
|
|
/// Database: AgentFrameworkTests
|
|
/// Container: Checkpoints
|
|
/// </summary>
|
|
[Collection("CosmosDB")]
|
|
public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
|
{
|
|
// Cosmos DB Emulator connection settings (can be overridden via COSMOSDB_ENDPOINT and COSMOSDB_KEY environment variables)
|
|
private static readonly string s_emulatorEndpoint = Environment.GetEnvironmentVariable("COSMOSDB_ENDPOINT") ?? "https://localhost:8081";
|
|
private static readonly string s_emulatorKey = Environment.GetEnvironmentVariable("COSMOSDB_KEY") ?? "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
|
|
private const string TestContainerId = "Checkpoints";
|
|
// Use unique database ID per test class instance to avoid conflicts
|
|
#pragma warning disable CA1802 // Use literals where appropriate
|
|
private static readonly string s_testDatabaseId = $"AgentFrameworkTests-CheckpointStore-{Guid.NewGuid():N}";
|
|
#pragma warning restore CA1802
|
|
|
|
private string _connectionString = string.Empty;
|
|
private CosmosClient? _cosmosClient;
|
|
private Database? _database;
|
|
private bool _emulatorAvailable;
|
|
private bool _preserveContainer;
|
|
|
|
// JsonSerializerOptions configured for .NET 9+ compatibility
|
|
private static readonly JsonSerializerOptions s_jsonOptions = CreateJsonOptions();
|
|
|
|
private static JsonSerializerOptions CreateJsonOptions()
|
|
{
|
|
var options = new JsonSerializerOptions();
|
|
#if NET9_0_OR_GREATER
|
|
options.TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver();
|
|
#endif
|
|
return options;
|
|
}
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
// Fail fast if emulator is not available
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Check environment variable to determine if we should preserve containers
|
|
// Set COSMOSDB_PRESERVE_CONTAINERS=true to keep containers and data for inspection
|
|
this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOSDB_PRESERVE_CONTAINERS"), bool.TrueString, StringComparison.OrdinalIgnoreCase);
|
|
|
|
this._connectionString = $"AccountEndpoint={s_emulatorEndpoint};AccountKey={s_emulatorKey}";
|
|
|
|
try
|
|
{
|
|
this._cosmosClient = new CosmosClient(s_emulatorEndpoint, s_emulatorKey);
|
|
|
|
// Test connection by attempting to create database
|
|
this._database = await this._cosmosClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId);
|
|
await this._database.CreateContainerIfNotExistsAsync(
|
|
TestContainerId,
|
|
"/sessionId",
|
|
throughput: 400);
|
|
|
|
this._emulatorAvailable = true;
|
|
}
|
|
catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
|
|
{
|
|
// Emulator not available, tests will be skipped
|
|
this._emulatorAvailable = false;
|
|
this._cosmosClient?.Dispose();
|
|
this._cosmosClient = null;
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
GC.SuppressFinalize(this);
|
|
|
|
if (this._cosmosClient != null && this._emulatorAvailable)
|
|
{
|
|
try
|
|
{
|
|
if (this._preserveContainer)
|
|
{
|
|
// Preserve mode: Don't delete the database/container, keep data for inspection
|
|
// This allows viewing data in the Cosmos DB Emulator Data Explorer
|
|
// No cleanup needed - data persists for debugging
|
|
}
|
|
else
|
|
{
|
|
// Clean mode: Delete the test database and all data
|
|
await this._database!.DeleteAsync();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Ignore cleanup errors, but log for diagnostics
|
|
Console.WriteLine($"[DisposeAsync] Cleanup error: {ex.Message}\n{ex.StackTrace}");
|
|
}
|
|
finally
|
|
{
|
|
this._cosmosClient.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void SkipIfEmulatorNotAvailable()
|
|
{
|
|
// In CI: Skip if COSMOSDB_EMULATOR_AVAILABLE is not set to "true"
|
|
// Locally: Skip if emulator connection check failed
|
|
var ciEmulatorAvailable = string.Equals(Environment.GetEnvironmentVariable("COSMOSDB_EMULATOR_AVAILABLE"), bool.TrueString, StringComparison.OrdinalIgnoreCase);
|
|
|
|
Assert.SkipWhen(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available");
|
|
}
|
|
|
|
#region Constructor Tests
|
|
|
|
[Fact]
|
|
public void Constructor_WithCosmosClient_SetsProperties()
|
|
{
|
|
// Arrange
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Act
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
|
|
// Assert
|
|
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
|
Assert.Equal(TestContainerId, store.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_WithConnectionString_SetsProperties()
|
|
{
|
|
// Arrange
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Act
|
|
using var store = new CosmosCheckpointStore(this._connectionString, s_testDatabaseId, TestContainerId);
|
|
|
|
// Assert
|
|
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
|
Assert.Equal(TestContainerId, store.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_WithNullCosmosClient_ThrowsArgumentNullException()
|
|
{
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new CosmosCheckpointStore((CosmosClient)null!, s_testDatabaseId, TestContainerId));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_WithNullConnectionString_ThrowsArgumentException()
|
|
{
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new CosmosCheckpointStore((string)null!, s_testDatabaseId, TestContainerId));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Checkpoint Operations Tests
|
|
|
|
[Fact]
|
|
public async Task CreateCheckpointAsync_NewCheckpoint_CreatesSuccessfullyAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var sessionId = Guid.NewGuid().ToString();
|
|
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test checkpoint" }, s_jsonOptions);
|
|
|
|
// Act
|
|
var checkpointInfo = await store.CreateCheckpointAsync(sessionId, checkpointValue);
|
|
|
|
// Assert
|
|
Assert.NotNull(checkpointInfo);
|
|
Assert.Equal(sessionId, checkpointInfo.SessionId);
|
|
Assert.NotNull(checkpointInfo.CheckpointId);
|
|
Assert.NotEmpty(checkpointInfo.CheckpointId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetrieveCheckpointAsync_ExistingCheckpoint_ReturnsCorrectValueAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var sessionId = Guid.NewGuid().ToString();
|
|
var originalData = new { message = "Hello, World!", timestamp = DateTimeOffset.UtcNow };
|
|
var checkpointValue = JsonSerializer.SerializeToElement(originalData, s_jsonOptions);
|
|
|
|
// Act
|
|
var checkpointInfo = await store.CreateCheckpointAsync(sessionId, checkpointValue);
|
|
var retrievedValue = await store.RetrieveCheckpointAsync(sessionId, checkpointInfo);
|
|
|
|
// Assert
|
|
Assert.Equal(JsonValueKind.Object, retrievedValue.ValueKind);
|
|
Assert.True(retrievedValue.TryGetProperty("message", out var messageProp));
|
|
Assert.Equal("Hello, World!", messageProp.GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetrieveCheckpointAsync_NonExistentCheckpoint_ThrowsInvalidOperationExceptionAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var sessionId = Guid.NewGuid().ToString();
|
|
var fakeCheckpointInfo = new CheckpointInfo(sessionId, "nonexistent-checkpoint");
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
store.RetrieveCheckpointAsync(sessionId, fakeCheckpointInfo).AsTask());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetrieveIndexAsync_EmptyStore_ReturnsEmptyCollectionAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var sessionId = Guid.NewGuid().ToString();
|
|
|
|
// Act
|
|
var index = await store.RetrieveIndexAsync(sessionId);
|
|
|
|
// Assert
|
|
Assert.NotNull(index);
|
|
Assert.Empty(index);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetrieveIndexAsync_WithCheckpoints_ReturnsAllCheckpointsAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var sessionId = Guid.NewGuid().ToString();
|
|
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
|
|
|
// Create multiple checkpoints
|
|
var checkpoint1 = await store.CreateCheckpointAsync(sessionId, checkpointValue);
|
|
var checkpoint2 = await store.CreateCheckpointAsync(sessionId, checkpointValue);
|
|
var checkpoint3 = await store.CreateCheckpointAsync(sessionId, checkpointValue);
|
|
|
|
// Act
|
|
var index = (await store.RetrieveIndexAsync(sessionId)).ToList();
|
|
|
|
// Assert
|
|
Assert.Equal(3, index.Count);
|
|
Assert.Contains(index, c => c.CheckpointId == checkpoint1.CheckpointId);
|
|
Assert.Contains(index, c => c.CheckpointId == checkpoint2.CheckpointId);
|
|
Assert.Contains(index, c => c.CheckpointId == checkpoint3.CheckpointId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateCheckpointAsync_WithParent_CreatesHierarchyAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var sessionId = Guid.NewGuid().ToString();
|
|
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
|
|
|
// Act
|
|
var parentCheckpoint = await store.CreateCheckpointAsync(sessionId, checkpointValue);
|
|
var childCheckpoint = await store.CreateCheckpointAsync(sessionId, checkpointValue, parentCheckpoint);
|
|
|
|
// Assert
|
|
Assert.NotEqual(parentCheckpoint.CheckpointId, childCheckpoint.CheckpointId);
|
|
Assert.Equal(sessionId, parentCheckpoint.SessionId);
|
|
Assert.Equal(sessionId, childCheckpoint.SessionId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetrieveIndexAsync_WithParentFilter_ReturnsFilteredResultsAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var sessionId = Guid.NewGuid().ToString();
|
|
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
|
|
|
// Create parent and child checkpoints
|
|
var parent = await store.CreateCheckpointAsync(sessionId, checkpointValue);
|
|
var child1 = await store.CreateCheckpointAsync(sessionId, checkpointValue, parent);
|
|
var child2 = await store.CreateCheckpointAsync(sessionId, checkpointValue, parent);
|
|
|
|
// Create an orphan checkpoint
|
|
var orphan = await store.CreateCheckpointAsync(sessionId, checkpointValue);
|
|
|
|
// Act
|
|
var allCheckpoints = (await store.RetrieveIndexAsync(sessionId)).ToList();
|
|
var childrenOfParent = (await store.RetrieveIndexAsync(sessionId, parent)).ToList();
|
|
|
|
// Assert
|
|
Assert.Equal(4, allCheckpoints.Count); // parent + 2 children + orphan
|
|
Assert.Equal(2, childrenOfParent.Count); // only children
|
|
|
|
Assert.Contains(childrenOfParent, c => c.CheckpointId == child1.CheckpointId);
|
|
Assert.Contains(childrenOfParent, c => c.CheckpointId == child2.CheckpointId);
|
|
Assert.DoesNotContain(childrenOfParent, c => c.CheckpointId == parent.CheckpointId);
|
|
Assert.DoesNotContain(childrenOfParent, c => c.CheckpointId == orphan.CheckpointId);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Run Isolation Tests
|
|
|
|
[Fact]
|
|
public async Task CheckpointOperations_DifferentRuns_IsolatesDataAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var sessionId1 = Guid.NewGuid().ToString();
|
|
var sessionId2 = Guid.NewGuid().ToString();
|
|
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
|
|
|
// Act
|
|
var checkpoint1 = await store.CreateCheckpointAsync(sessionId1, checkpointValue);
|
|
var checkpoint2 = await store.CreateCheckpointAsync(sessionId2, checkpointValue);
|
|
|
|
var index1 = (await store.RetrieveIndexAsync(sessionId1)).ToList();
|
|
var index2 = (await store.RetrieveIndexAsync(sessionId2)).ToList();
|
|
|
|
// Assert
|
|
Assert.Single(index1);
|
|
Assert.Single(index2);
|
|
Assert.Equal(checkpoint1.CheckpointId, index1[0].CheckpointId);
|
|
Assert.Equal(checkpoint2.CheckpointId, index2[0].CheckpointId);
|
|
Assert.NotEqual(checkpoint1.CheckpointId, checkpoint2.CheckpointId);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Error Handling Tests
|
|
|
|
[Fact]
|
|
public async Task CreateCheckpointAsync_WithNullSessionId_ThrowsArgumentExceptionAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
|
store.CreateCheckpointAsync(null!, checkpointValue).AsTask());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateCheckpointAsync_WithEmptySessionId_ThrowsArgumentExceptionAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
|
store.CreateCheckpointAsync("", checkpointValue).AsTask());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetrieveCheckpointAsync_WithNullCheckpointInfo_ThrowsArgumentNullExceptionAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var sessionId = Guid.NewGuid().ToString();
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
|
store.RetrieveCheckpointAsync(sessionId, null!).AsTask());
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Disposal Tests
|
|
|
|
[Fact]
|
|
public async Task Dispose_AfterDisposal_ThrowsObjectDisposedExceptionAsync()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
|
|
|
// Act
|
|
store.Dispose();
|
|
|
|
// Assert
|
|
await Assert.ThrowsAsync<ObjectDisposedException>(() =>
|
|
store.CreateCheckpointAsync("test-run", checkpointValue).AsTask());
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_MultipleCalls_DoesNotThrow()
|
|
{
|
|
this.SkipIfEmulatorNotAvailable();
|
|
|
|
// Arrange
|
|
var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
|
|
|
// Act & Assert (should not throw)
|
|
store.Dispose();
|
|
store.Dispose();
|
|
store.Dispose();
|
|
}
|
|
|
|
#endregion
|
|
|
|
public void Dispose()
|
|
{
|
|
this.Dispose(true);
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
protected virtual void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
this._cosmosClient?.Dispose();
|
|
}
|
|
}
|
|
}
|