diff --git a/Directory.Packages.props b/Directory.Packages.props index 3585406b..76c4dd51 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -51,11 +51,11 @@ - + - - - + + + diff --git a/src/InProcessTestHost/DurableTaskTestExtensions.cs b/src/InProcessTestHost/DurableTaskTestExtensions.cs new file mode 100644 index 00000000..ca4bb64e --- /dev/null +++ b/src/InProcessTestHost/DurableTaskTestExtensions.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DurableTask.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Grpc; +using Microsoft.DurableTask.Testing.Sidecar; +using Microsoft.DurableTask.Testing.Sidecar.Grpc; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.Grpc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.Testing; + +/// +/// Extension methods for integrating in-memory durable task testing with your existing DI container, +/// such as WebApplicationFactory. +/// +public static class DurableTaskTestExtensions +{ + /// + /// These extensions allow you to inject the into your + /// existing test host so that your orchestrations and activities can resolve services from your DI container. + /// + /// The service collection (from your WebApplicationFactory or host). + /// Action to register orchestrators and activities. + /// Optional configuration options. + /// The service collection for chaining. + public static IServiceCollection AddInMemoryDurableTask( + this IServiceCollection services, + Action configureTasks, + InMemoryDurableTaskOptions? options = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureTasks); + + options ??= new InMemoryDurableTaskOptions(); + + // Determine port for the internal gRPC server + int port = options.Port ?? Random.Shared.Next(30000, 40000); + string address = $"http://localhost:{port}"; + + // Register the in-memory orchestration service as a singleton + services.AddSingleton(sp => + { + var loggerFactory = sp.GetService(); + return new InMemoryOrchestrationService(loggerFactory); + }); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + + // Register the gRPC sidecar server as a hosted service + services.AddSingleton(); + services.AddHostedService(sp => + { + return new InMemoryGrpcSidecarHost( + address, + sp.GetRequiredService(), + sp.GetService()); + }); + + // Create a gRPC channel that will connect to our internal sidecar + services.AddSingleton(sp => GrpcChannel.ForAddress(address)); + + // Register the durable task worker (connects to our internal sidecar) + services.AddDurableTaskWorker(builder => + { + builder.UseGrpc(address); + builder.AddTasks(configureTasks); + }); + + // Register the durable task client (connects to our internal sidecar) + services.AddDurableTaskClient(builder => + { + builder.UseGrpc(address); + builder.RegisterDirectly(); + }); + + return services; + } + + /// + /// Gets the from the service provider. + /// Useful for advanced scenarios like inspecting orchestration state. + /// + /// The service provider. + /// The in-memory orchestration service instance. + public static InMemoryOrchestrationService GetInMemoryOrchestrationService(this IServiceProvider services) + { + return services.GetRequiredService(); + } +} + +/// +/// Options for configuring in-memory durable task support. +/// +public class InMemoryDurableTaskOptions +{ + /// + /// Gets or sets the port for the internal gRPC server. + /// If not set, a random port between 30000-40000 will be used. + /// + public int? Port { get; set; } +} + +/// +/// Internal hosted service that runs the gRPC sidecar within the user's host. +/// +sealed class InMemoryGrpcSidecarHost : IHostedService, IAsyncDisposable +{ + readonly string address; + readonly InMemoryOrchestrationService orchestrationService; + readonly ILoggerFactory? loggerFactory; + IHost? inMemorySidecarHost; + + public InMemoryGrpcSidecarHost( + string address, + InMemoryOrchestrationService orchestrationService, + ILoggerFactory? loggerFactory) + { + this.address = address; + this.orchestrationService = orchestrationService; + this.loggerFactory = loggerFactory; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + // Build and start the gRPC sidecar + this.inMemorySidecarHost = Host.CreateDefaultBuilder() + .ConfigureLogging(logging => + { + logging.ClearProviders(); + if (this.loggerFactory != null) + { + logging.Services.AddSingleton(this.loggerFactory); + } + }) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseUrls(this.address); + webBuilder.ConfigureKestrel(kestrelOptions => + { + kestrelOptions.ConfigureEndpointDefaults(listenOptions => + listenOptions.Protocols = HttpProtocols.Http2); + }); + + webBuilder.ConfigureServices(services => + { + services.AddGrpc(); + // Use the SAME orchestration service instance + services.AddSingleton(this.orchestrationService); + services.AddSingleton(this.orchestrationService); + services.AddSingleton(); + }); + + webBuilder.Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapGrpcService(); + }); + }); + }) + .Build(); + + await this.inMemorySidecarHost.StartAsync(cancellationToken); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (this.inMemorySidecarHost != null) + { + await this.inMemorySidecarHost.StopAsync(cancellationToken); + } + } + + public async ValueTask DisposeAsync() + { + if (this.inMemorySidecarHost != null) + { + await this.inMemorySidecarHost.StopAsync(); + this.inMemorySidecarHost.Dispose(); + } + } +} diff --git a/src/InProcessTestHost/DurableTaskTestHost.cs b/src/InProcessTestHost/DurableTaskTestHost.cs index 931912ef..016b86e1 100644 --- a/src/InProcessTestHost/DurableTaskTestHost.cs +++ b/src/InProcessTestHost/DurableTaskTestHost.cs @@ -46,6 +46,12 @@ public DurableTaskTestHost(IHost sidecarHost, IHost workerHost, GrpcChannel grpc /// public DurableTaskClient Client { get; } + /// + /// Gets the service provider from the worker host. + /// Use this to resolve services registered via . + /// + public IServiceProvider Services => this.workerHost.Services; + /// /// Starts a new in-process test host with the specified orchestrators and activities. /// @@ -113,6 +119,10 @@ public static async Task StartAsync( }) .ConfigureServices(services => { + // Allow user to register their own services FIRST + // This ensures their services are available when activities are resolved + options.ConfigureServices?.Invoke(services); + // Register worker that connects to our in-process sidecar services.AddDurableTaskWorker(builder => { @@ -170,4 +180,10 @@ public class DurableTaskTestHostOptions /// Null by default. /// public ILoggerFactory? LoggerFactory { get; set; } + + /// + /// Gets or sets an optional callback to configure additional services in the worker host's DI container. + /// Use this to register services that your activities and orchestrators depend on. + /// + public Action? ConfigureServices { get; set; } } diff --git a/src/InProcessTestHost/InProcessTestHost.csproj b/src/InProcessTestHost/InProcessTestHost.csproj index 45c263e7..13aee25e 100644 --- a/src/InProcessTestHost/InProcessTestHost.csproj +++ b/src/InProcessTestHost/InProcessTestHost.csproj @@ -5,7 +5,7 @@ Microsoft.DurableTask.Testing Microsoft.DurableTask.InProcessTestHost Microsoft.DurableTask.InProcessTestHost - 0.1.0-preview.1 + 0.2.0-preview.1 $(NoWarn);CA1848 diff --git a/src/InProcessTestHost/README.md b/src/InProcessTestHost/README.md index 754f556a..0743e0e1 100644 --- a/src/InProcessTestHost/README.md +++ b/src/InProcessTestHost/README.md @@ -6,17 +6,17 @@ Supports both **class-based** and **function-based** syntax. ## Quick Start -1. Configure options +### 1. Configure options (optional) + ```csharp var options = new DurableTaskTestHostOptions { Port = 31000, // Optional: specific port (random by default) LoggerFactory = myLoggerFactory // Optional: pass logger factory for logging }; - ``` -2. Register test orchestrations and activities. +### 2. Register test orchestrations and activities ```csharp await using var testHost = await DurableTaskTestHost.StartAsync(registry => @@ -29,15 +29,121 @@ await using var testHost = await DurableTaskTestHost.StartAsync(registry => registry.AddOrchestratorFunc("MyFunc", (ctx, input) => Task.FromResult("done")); registry.AddActivityFunc("MyActivity", (ctx, input) => Task.FromResult("result")); }); - ``` -3. Test +### 3. Test + ```csharp string instanceId = await testHost.Client.ScheduleNewOrchestrationInstanceAsync("MyOrchestrator"); var result = await testHost.Client.WaitForInstanceCompletionAsync(instanceId); ``` - . + +## Dependency Injection + +When your activities depend on services, there are two approaches: + +| Approach | When to Use | +|----------|-------------| +| **Option 1: ConfigureServices** | Simple tests where you register a few services directly | +| **Option 2: AddInMemoryDurableTask** | When you have an existing host (e.g., `WebApplicationFactory`) with complex DI setup | + +### Option 1: ConfigureServices + +Use this when you want the test host to manage everything. Register services directly in the test host options. + +```csharp +await using var host = await DurableTaskTestHost.StartAsync( + tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }, + new DurableTaskTestHostOptions + { + ConfigureServices = services => + { + // Register services required by your orchestrator or activity function + services.AddSingleton(); + services.AddSingleton(); + services.AddLogging(); + } + }); + +var instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync(nameof(MyOrchestrator), "input"); +var result = await host.Client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true); +``` + +Access registered services via `host.Services`: + +```csharp +var myService = host.Services.GetRequiredService(); +``` + +### Option 2: AddInMemoryDurableTask + +Use this when you already have a host with complex DI setup (database, auth, external APIs, etc.) and want to add durable task testing to it. + +```csharp +public class MyIntegrationTests : IAsyncLifetime +{ + IHost host = null!; + DurableTaskClient client = null!; + + public async Task InitializeAsync() + { + this.host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + // Your existing services (from Program.cs, Startup.cs, etc.) + services.AddSingleton(); + services.AddScoped(); + services.AddDbContext(); + + // Add in-memory durable task support + services.AddInMemoryDurableTask(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); + }) + .Build(); + + await this.host.StartAsync(); + this.client = this.host.Services.GetRequiredService(); + } +} +``` + +Access the in-memory orchestration service: + +```csharp +var orchestrationService = host.Services.GetInMemoryOrchestrationService(); +``` + +## API Reference + +### DurableTaskTestHostOptions + +| Property | Type | Description | +|----------|------|-------------| +| `Port` | `int?` | Specific port for gRPC sidecar. Random 30000-40000 if not set. | +| `LoggerFactory` | `ILoggerFactory?` | Logger factory for capturing logs during tests. | +| `ConfigureServices` | `Action?` | Callback to register services for DI. | + +### DurableTaskTestHost + +| Property | Type | Description | +|----------|------|-------------| +| `Client` | `DurableTaskClient` | Client for scheduling and managing orchestrations. | +| `Services` | `IServiceProvider` | Service provider with registered services. | + +### Extension Methods + +| Method | Description | +|--------|-------------| +| `services.AddInMemoryDurableTask(configureTasks)` | Adds in-memory durable task support to an existing `IServiceCollection`. | +| `services.GetInMemoryOrchestrationService()` | Gets the `InMemoryOrchestrationService` from the service provider. | + ## More Samples -See [BasicOrchestrationTests.cs](../../test/InProcessTestHost.Tests/BasicOrchestrationTests.cs) for complete samples showing both class-syntax and function-syntax orchestrations. +See [BasicOrchestrationTests.cs](../../test/InProcessTestHost.Tests/BasicOrchestrationTests.cs), [DependencyInjectionTests.cs](../../test/InProcessTestHost.Tests/DependencyInjectionTests.cs), and [WebApplicationFactoryIntegrationTests.cs](../../test/InProcessTestHost.Tests/WebApplicationFactoryIntegrationTests.cs) for complete samples. diff --git a/test/InProcessTestHost.Tests/BasicOrchestrationTests.cs b/test/InProcessTestHost.Tests/BasicOrchestrationTests.cs index 3e14077f..e51dd151 100644 --- a/test/InProcessTestHost.Tests/BasicOrchestrationTests.cs +++ b/test/InProcessTestHost.Tests/BasicOrchestrationTests.cs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Microsoft.DurableTask; using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Testing; diff --git a/test/InProcessTestHost.Tests/DependencyInjectionTests.cs b/test/InProcessTestHost.Tests/DependencyInjectionTests.cs new file mode 100644 index 00000000..6f0fe72a --- /dev/null +++ b/test/InProcessTestHost.Tests/DependencyInjectionTests.cs @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Testing; +using Microsoft.DurableTask.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace InProcessTestHost.Tests; + +/// +/// Tests to verify DurableTaskTestHost works with dependency injection. +/// +public class DependencyInjectionTests +{ + /// + /// Verifies an activity can resolve a service registered via ConfigureServices. + /// + [Fact] + public async Task Activity_CanResolveService_FromDI() + { + await using var host = await DurableTaskTestHost.StartAsync( + tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }, + new DurableTaskTestHostOptions + { + ConfigureServices = services => + { + services.AddSingleton(); + } + }); + + var instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync( + nameof(GreetingOrchestrator), + "World"); + + var result = await host.Client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true); + + Assert.NotNull(result); + Assert.Equal(OrchestrationRuntimeStatus.Completed, result.RuntimeStatus); + var output = result.ReadOutputAs(); + Assert.Equal("Hello, World! (from DI service)", output); + } + + /// + /// Verifies multiple activities can share the same DI-registered service. + /// + [Fact] + public async Task Activity_CanUseMultipleServices_FromDI() + { + await using var host = await DurableTaskTestHost.StartAsync( + tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + tasks.AddActivity(); + }, + new DurableTaskTestHostOptions + { + ConfigureServices = services => + { + services.AddSingleton(); + } + }); + + var instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync( + nameof(CalculatorOrchestrator), + new CalculatorInput { A = 5, B = 3 }); + + var result = await host.Client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true); + + Assert.NotNull(result); + Assert.Equal(OrchestrationRuntimeStatus.Completed, result.RuntimeStatus); + var output = result.ReadOutputAs(); + Assert.NotNull(output); + Assert.Equal(8, output.Sum); // 5 + 3 = 8 + Assert.Equal(15, output.Product); // 5 * 3 = 15 + } + + /// + /// Verifies host.Services exposes the DI container for direct service access. + /// + [Fact] + public async Task Services_Property_AllowsAccessToRegisteredServices() + { + await using var host = await DurableTaskTestHost.StartAsync( + tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }, + new DurableTaskTestHostOptions + { + ConfigureServices = services => + { + services.AddSingleton(); + } + }); + + var greetingService = host.Services.GetRequiredService(); + + Assert.NotNull(greetingService); + Assert.Equal("Hello, Test! (from DI service)", greetingService.Greet("Test")); + } + + /// + /// Verifies scoped services get a fresh instance per activity execution. + /// + [Fact] + public async Task ScopedServices_AreResolvedPerActivityExecution() + { + await using var host = await DurableTaskTestHost.StartAsync( + tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }, + new DurableTaskTestHostOptions + { + ConfigureServices = services => + { + services.AddScoped(); + } + }); + + var instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync( + nameof(CountingOrchestrator)); + + var result = await host.Client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true); + + Assert.NotNull(result); + Assert.Equal(OrchestrationRuntimeStatus.Completed, result.RuntimeStatus); + + var output = result.ReadOutputAs(); + Assert.NotNull(output); + Assert.Equal(2, output.Length); + Assert.Equal(1, output[0]); + Assert.Equal(1, output[1]); + } + + #region Test Services + + public interface IGreetingService + { + string Greet(string name); + } + + public class GreetingService : IGreetingService + { + public string Greet(string name) => $"Hello, {name}! (from DI service)"; + } + + public interface ICalculatorService + { + int Add(int a, int b); + int Multiply(int a, int b); + } + + public class CalculatorService : ICalculatorService + { + public int Add(int a, int b) => a + b; + public int Multiply(int a, int b) => a * b; + } + + public interface ICounterService + { + int Increment(); + } + + public class CounterService : ICounterService + { + private int count; + public int Increment() => ++this.count; + } + + #endregion + + #region Test DTOs + + public class CalculatorInput + { + public int A { get; set; } + public int B { get; set; } + } + + public class CalculatorOutput + { + public int Sum { get; set; } + public int Product { get; set; } + } + + #endregion + + #region Test Orchestrators and Activities + + /// + /// Orchestrator that calls an activity with DI dependencies. + /// + public class GreetingOrchestrator : TaskOrchestrator + { + public override async Task RunAsync(TaskOrchestrationContext context, string name) + { + return await context.CallActivityAsync(nameof(GreetingActivity), name); + } + } + + /// + /// Activity that uses a service resolved from DI. + /// + public class GreetingActivity : TaskActivity + { + private readonly IGreetingService greetingService; + + public GreetingActivity(IGreetingService greetingService) + { + this.greetingService = greetingService; + } + + public override Task RunAsync(TaskActivityContext context, string name) + { + return Task.FromResult(this.greetingService.Greet(name)); + } + } + + /// + /// Orchestrator that calls multiple activities. + /// + public class CalculatorOrchestrator : TaskOrchestrator + { + public override async Task RunAsync(TaskOrchestrationContext context, CalculatorInput input) + { + var sumTask = context.CallActivityAsync(nameof(AddActivity), input); + var productTask = context.CallActivityAsync(nameof(MultiplyActivity), input); + + await Task.WhenAll(sumTask, productTask); + + return new CalculatorOutput + { + Sum = sumTask.Result, + Product = productTask.Result + }; + } + } + + public class AddActivity : TaskActivity + { + private readonly ICalculatorService calculator; + + public AddActivity(ICalculatorService calculator) + { + this.calculator = calculator; + } + + public override Task RunAsync(TaskActivityContext context, CalculatorInput input) + { + return Task.FromResult(this.calculator.Add(input.A, input.B)); + } + } + + public class MultiplyActivity : TaskActivity + { + private readonly ICalculatorService calculator; + + public MultiplyActivity(ICalculatorService calculator) + { + this.calculator = calculator; + } + + public override Task RunAsync(TaskActivityContext context, CalculatorInput input) + { + return Task.FromResult(this.calculator.Multiply(input.A, input.B)); + } + } + + /// + /// Orchestrator that tests scoped services. + /// + public class CountingOrchestrator : TaskOrchestrator + { + public override async Task RunAsync(TaskOrchestrationContext context, object? input) + { + // Call the same activity twice + var count1 = await context.CallActivityAsync(nameof(CountingActivity)); + var count2 = await context.CallActivityAsync(nameof(CountingActivity)); + + return new[] { count1, count2 }; + } + } + + public class CountingActivity : TaskActivity + { + private readonly ICounterService counter; + + public CountingActivity(ICounterService counter) + { + this.counter = counter; + } + + public override Task RunAsync(TaskActivityContext context, object? input) + { + return Task.FromResult(this.counter.Increment()); + } + } + + #endregion +} diff --git a/test/InProcessTestHost.Tests/WebApplicationFactoryIntegrationTests.cs b/test/InProcessTestHost.Tests/WebApplicationFactoryIntegrationTests.cs new file mode 100644 index 00000000..6ff4b85c --- /dev/null +++ b/test/InProcessTestHost.Tests/WebApplicationFactoryIntegrationTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace InProcessTestHost.Tests; + +/// +/// Tests for AddInMemoryDurableTask() extension which allows injecting +/// InMemoryOrchestrationService into an existing host (e.g., WebApplicationFactory). +/// +public class WebApplicationFactoryIntegrationTests : IAsyncLifetime +{ + IHost host = null!; + DurableTaskClient client = null!; + + public async Task InitializeAsync() + { + this.host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddLogging(logging => logging.AddDebug()); + + services.AddInMemoryDurableTask(tasks => + { + tasks.AddOrchestrator(); + tasks.AddOrchestrator(); + tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddActivity(); + }); + }) + .Build(); + + await this.host.StartAsync(); + this.client = this.host.Services.GetRequiredService(); + } + + public async Task DisposeAsync() + { + await this.host.StopAsync(); + this.host.Dispose(); + } + + /// + /// Verifies activities can resolve services from the host's DI container. + /// + [Fact] + public async Task Activity_ResolvesServices_FromDIContainer() + { + var userRepo = this.host.Services.GetRequiredService(); + userRepo.Add(new User { Id = 1, Name = "Alice", Email = "alice@example.com" }); + + var instanceId = await this.client.ScheduleNewOrchestrationInstanceAsync(nameof(UserLookupOrchestrator), 1); + var result = await this.client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true); + + Assert.NotNull(result); + Assert.Equal(OrchestrationRuntimeStatus.Completed, result.RuntimeStatus); + var user = result.ReadOutputAs(); + Assert.NotNull(user); + Assert.Equal("Alice", user.Name); + } + + /// + /// Verifies a multi-step orchestration can use multiple services from DI. + /// + [Fact] + public async Task ComplexOrchestration_UsesMultipleServicesFromDI() + { + var userRepo = this.host.Services.GetRequiredService(); + var orderRepo = this.host.Services.GetRequiredService(); + userRepo.Add(new User { Id = 1, Name = "Bob", Email = "bob@test.com" }); + orderRepo.Add(new Order { Id = 100, UserId = 1, Amount = 99.99m, Status = "Pending" }); + + var instanceId = await this.client.ScheduleNewOrchestrationInstanceAsync( + nameof(OrderProcessingOrchestrator), + new OrderInput { OrderId = 100, UserId = 1 }); + var result = await this.client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true); + + Assert.NotNull(result); + Assert.Equal(OrchestrationRuntimeStatus.Completed, result.RuntimeStatus); + var output = result.ReadOutputAs(); + Assert.NotNull(output); + Assert.True(output.Success); + Assert.Equal("Completed", output.Status); + + var order = orderRepo.GetById(100); + Assert.Equal("Completed", order?.Status); + } + + /// + /// Verifies InMemoryOrchestrationService is accessible from DI. + /// + [Fact] + public async Task InMemoryOrchestrationService_IsAccessible() + { + var orchestrationService = this.host.Services.GetInMemoryOrchestrationService(); + Assert.NotNull(orchestrationService); + } +} diff --git a/test/InProcessTestHost.Tests/WebApplicationFactoryTestHelpers.cs b/test/InProcessTestHost.Tests/WebApplicationFactoryTestHelpers.cs new file mode 100644 index 00000000..96f7fb00 --- /dev/null +++ b/test/InProcessTestHost.Tests/WebApplicationFactoryTestHelpers.cs @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Worker; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; + +namespace InProcessTestHost.Tests; + +// This file contains test helper types for WebApplicationFactoryIntegrationTests. +// It includes sample entities, repositories, services, orchestrators, and activities +// that demonstrate how to use AddInMemoryDurableTask() with dependency injection. +public class User +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; +} + +public class Order +{ + public int Id { get; set; } + public int UserId { get; set; } + public decimal Amount { get; set; } + public string Status { get; set; } = ""; +} + +public class UserDto +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; +} + +public class OrderInput +{ + public int OrderId { get; set; } + public int UserId { get; set; } +} + +public class OrderOutput +{ + public bool Success { get; set; } + public string Status { get; set; } = ""; +} + +public interface IUserRepository +{ + void Add(User user); + User? GetById(int id); +} + +public class InMemoryUserRepository : IUserRepository +{ + readonly ConcurrentDictionary users = new(); + + public void Add(User user) => this.users[user.Id] = user; + public User? GetById(int id) => this.users.TryGetValue(id, out var u) ? u : null; +} + +public interface IOrderRepository +{ + void Add(Order order); + Order? GetById(int id); + void Update(Order order); +} + +public class InMemoryOrderRepository : IOrderRepository +{ + readonly ConcurrentDictionary orders = new(); + + public void Add(Order order) => this.orders[order.Id] = order; + public Order? GetById(int id) => this.orders.TryGetValue(id, out var o) ? o : null; + public void Update(Order order) => this.orders[order.Id] = order; +} + +public interface IUserService +{ + UserDto? GetUser(int id); +} + +public class UserService : IUserService +{ + readonly IUserRepository userRepository; + + public UserService(IUserRepository userRepository) => this.userRepository = userRepository; + + public UserDto? GetUser(int id) + { + var u = this.userRepository.GetById(id); + return u == null ? null : new UserDto { Id = u.Id, Name = u.Name, Email = u.Email }; + } +} + +public interface IOrderService +{ + Order? GetOrder(int id); + void UpdateStatus(int id, string status); +} + +public class OrderService : IOrderService +{ + readonly IOrderRepository orderRepository; + + public OrderService(IOrderRepository orderRepository) => this.orderRepository = orderRepository; + + public Order? GetOrder(int id) => this.orderRepository.GetById(id); + + public void UpdateStatus(int id, string status) + { + var order = this.orderRepository.GetById(id); + if (order != null) + { + order.Status = status; + this.orderRepository.Update(order); + } + } +} + +public interface IPaymentService +{ + bool ProcessPayment(int orderId, decimal amount); +} + +public class PaymentService : IPaymentService +{ + readonly ILogger logger; + + public PaymentService(ILogger logger) => this.logger = logger; + + public bool ProcessPayment(int orderId, decimal amount) + { + this.logger.LogInformation("Processing payment of {Amount} for order {OrderId}", amount, orderId); + return true; + } +} + +public class UserLookupOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext ctx, int userId) + { + return await ctx.CallActivityAsync(nameof(GetUserActivity), userId); + } +} + +public class OrderProcessingOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext ctx, OrderInput input) + { + var isValid = await ctx.CallActivityAsync(nameof(ValidateOrderActivity), input); + if (!isValid) + { + return new OrderOutput { Success = false, Status = "Invalid" }; + } + + var paid = await ctx.CallActivityAsync(nameof(ProcessPaymentActivity), input.OrderId); + if (!paid) + { + return new OrderOutput { Success = false, Status = "PaymentFailed" }; + } + + await ctx.CallActivityAsync(nameof(UpdateOrderStatusActivity), input.OrderId); + return new OrderOutput { Success = true, Status = "Completed" }; + } +} + +public class GetUserActivity : TaskActivity +{ + readonly IUserService userService; + + public GetUserActivity(IUserService userService) => this.userService = userService; + + public override Task RunAsync(TaskActivityContext ctx, int userId) + { + return Task.FromResult(this.userService.GetUser(userId)); + } +} + +public class ValidateOrderActivity : TaskActivity +{ + readonly IOrderService orderService; + + public ValidateOrderActivity(IOrderService orderService) => this.orderService = orderService; + + public override Task RunAsync(TaskActivityContext ctx, OrderInput input) + { + var order = this.orderService.GetOrder(input.OrderId); + return Task.FromResult(order != null && order.UserId == input.UserId && order.Amount > 0); + } +} + +public class ProcessPaymentActivity : TaskActivity +{ + readonly IOrderService orderService; + readonly IPaymentService paymentService; + + public ProcessPaymentActivity(IOrderService orderService, IPaymentService paymentService) + { + this.orderService = orderService; + this.paymentService = paymentService; + } + + public override Task RunAsync(TaskActivityContext ctx, int orderId) + { + var order = this.orderService.GetOrder(orderId); + if (order == null) + { + return Task.FromResult(false); + } + + return Task.FromResult(this.paymentService.ProcessPayment(orderId, order.Amount)); + } +} + +public class UpdateOrderStatusActivity : TaskActivity +{ + readonly IOrderService orderService; + + public UpdateOrderStatusActivity(IOrderService orderService) => this.orderService = orderService; + + public override Task RunAsync(TaskActivityContext ctx, int orderId) + { + this.orderService.UpdateStatus(orderId, "Completed"); + return Task.FromResult(null); + } +}