Add StellaOps.Workflow engine: 14 libraries, WebService, 8 test projects

Extract product-agnostic workflow engine from Ablera.Serdica.Workflow into
standalone StellaOps.Workflow.* libraries targeting net10.0.

Libraries (14):
- Contracts, Abstractions (compiler, decompiler, expression runtime)
- Engine (execution, signaling, scheduling, projections, hosted services)
- ElkSharp (generic graph layout algorithm)
- Renderer.ElkSharp, Renderer.ElkJs, Renderer.Msagl, Renderer.Svg
- Signaling.Redis, Signaling.OracleAq
- DataStore.MongoDB, DataStore.PostgreSQL, DataStore.Oracle

WebService: ASP.NET Core Minimal API with 22 endpoints

Tests (8 projects, 109 tests pass):
- Engine.Tests (105 pass), WebService.Tests (4 E2E pass)
- Renderer.Tests, DataStore.MongoDB/Oracle/PostgreSQL.Tests
- Signaling.Redis.Tests, IntegrationTests.Shared

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-20 19:14:44 +02:00
parent e56f9a114a
commit f5b5f24d95
422 changed files with 85428 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
# Tutorial 9: Testing Your Workflow
Write unit tests for workflows using `RecordingSerdicaLegacyRabbitTransport` and `TechnicalStyleWorkflowTestHelpers`.
## Test Setup Pattern
1. Create a recording transport with pre-configured responses
2. Build a test service provider via `TechnicalStyleWorkflowTestHelpers.CreateServiceProvider`
3. Resolve `WorkflowRuntimeService` from DI
4. Call `StartWorkflowAsync` with test payload
5. Assert: tasks created, transport calls made, state values correct
6. Optionally complete tasks and verify downstream behavior
## What to Test
| Scenario | Approach |
|----------|----------|
| Workflow starts correctly | Assert single open task after start |
| Service calls made in order | `transport.Invocations.Select(x => x.Command).Should().Equal(...)` |
| Rejection flow | Complete task with `"answer": "reject"`, verify cancel call |
| Approval flow | Complete with `"answer": "approve"`, verify conversion calls |
| Operations failure re-opens task | Check same task re-appears after operations return `passed: false` |
| Sub-workflow creates child tasks | Query tasks by child workflow name |
| Business reference set | `startResponse.BusinessReference.Key.Should().Be(...)` |
## C#-Only Tutorial
- [C# Test Examples](csharp/)

View File

@@ -0,0 +1,196 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using WorkflowEngine.Abstractions;
using WorkflowEngine.Contracts;
using WorkflowEngine.Services;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace WorkflowEngine.Tutorials.Tests;
[TestFixture]
public class WorkflowTestExamples
{
// ═══════════════════════════════════════════════
// BASIC: Start workflow and verify task created
// ═══════════════════════════════════════════════
[Test]
public async Task Workflow_WhenStarted_ShouldCreateOpenTask()
{
// 1. Configure fake transport responses
var transport = new RecordingSerdicaLegacyRabbitTransport()
.Respond("pas_policy_validate", new { valid = true })
.Respond("pas_get_policy_product_info", new
{
productCode = "4704",
lob = "MOT",
contractType = "STANDARD",
});
// 2. Build test service provider
using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
transport,
WorkflowRuntimeProviderNames.SerdicaEngine);
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
// 3. Start the workflow
var start = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "ApproveApplication",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 12345L,
["srAnnexId"] = 67890L,
["srCustId"] = 11111L,
},
});
// 4. Assert task was created
var tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = start.WorkflowInstanceId,
Status = "Open",
});
tasks.Tasks.Should().ContainSingle();
tasks.Tasks.Single().TaskName.Should().Be("Approve Application");
}
// ═══════════════════════════════════════════════
// VERIFY: Service calls made in order
// ═══════════════════════════════════════════════
[Test]
public async Task Workflow_WhenStarted_ShouldCallServicesInOrder()
{
var transport = new RecordingSerdicaLegacyRabbitTransport()
.Respond("pas_annexprocessing_alterpolicy", new
{
srPolicyId = 1L,
srAnnexId = 2L,
previouslyOpened = false,
})
.Respond("pas_polclmparticipants_create", new { ok = true })
.Respond("pas_premium_calculate_for_object", new { ok = true },
SerdicaLegacyRabbitMode.MicroserviceConsumer)
.Respond("pas_polannexes_get", new
{
shortDescription = "Test annex",
policyNo = "POL-001",
});
using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
transport,
WorkflowRuntimeProviderNames.SerdicaEngine);
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "AssistantAddAnnex",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 1L,
["srAnnexId"] = 2L,
["policyExistsOnIPAL"] = true,
["annexPreviouslyOpened"] = false,
["annexType"] = "BENEF",
["entityData"] = new { srCustId = 3L },
},
});
// Verify exact call sequence
transport.Invocations.Select(x => x.Command)
.Should().Equal(
"pas_annexprocessing_alterpolicy",
"pas_polclmparticipants_create",
"pas_premium_calculate_for_object",
"pas_polannexes_get");
}
// ═══════════════════════════════════════════════
// TASK COMPLETION: Approve/reject flows
// ═══════════════════════════════════════════════
[Test]
public async Task Workflow_WhenTaskCompleted_ShouldExecuteOnCompleteFlow()
{
var transport = new RecordingSerdicaLegacyRabbitTransport()
.Respond("pas_operations_perform", new { passed = true, nextStep = "CONTINUE" },
SerdicaLegacyRabbitMode.MicroserviceConsumer)
.Respond("pas_polreg_convertapltopol", new { ok = true })
.Respond("pas_annexprocessing_generatepolicyno", new { ok = true })
.Respond("pas_get_policy_product_info", new
{
productCode = "4704",
lob = "MOT",
contractType = "STANDARD",
});
using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
transport,
WorkflowRuntimeProviderNames.SerdicaEngine);
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
// Start workflow
var start = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "ApproveApplication",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 1L,
["srAnnexId"] = 2L,
["srCustId"] = 3L,
},
});
// Get the open task
var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = start.WorkflowInstanceId,
Status = "Open",
})).Tasks.Single();
// Complete with "approve"
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = task.WorkflowTaskId,
ActorId = "test-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?> { ["answer"] = "approve" },
});
// Verify operations and conversion were called
transport.Invocations.Should().Contain(x => x.Command == "pas_operations_perform");
transport.Invocations.Should().Contain(x => x.Command == "pas_polreg_convertapltopol");
}
// ═══════════════════════════════════════════════
// RECORDING TRANSPORT: multiple responses
// ═══════════════════════════════════════════════
[Test]
public void RecordingTransport_CanConfigureMultipleResponses()
{
var transport = new RecordingSerdicaLegacyRabbitTransport()
// Default mode (Envelope)
.Respond("command_a", new { result = "first" })
// Specific mode
.Respond("command_b", new { result = "second" },
SerdicaLegacyRabbitMode.MicroserviceConsumer)
// Same command, different responses (returned in order)
.Respond("command_c", new { attempt = 1 })
.Respond("command_c", new { attempt = 2 });
// After workflow execution, inspect:
// transport.Invocations — list of all calls made
// transport.Invocations[0].Command — command name
// transport.Invocations[0].Payload — request payload
transport.Should().NotBeNull();
}
}