// 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; /// /// Contains tests for . /// /// 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 /// [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(() => new CosmosCheckpointStore((CosmosClient)null!, s_testDatabaseId, TestContainerId)); } [Fact] public void Constructor_WithNullConnectionString_ThrowsArgumentException() { // Act & Assert Assert.Throws(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(); } } }