w4c-workflows-api/w4c-workflows-api.Tests/WorkflowSyncIntegrationTests.cs

273 lines
9.5 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using w4c_workflows.Models;
using w4c_workflows.Services;
using Xunit;
namespace w4c_workflows.Tests;
[Collection("WorkflowsPostgres")]
public class WorkflowSyncIntegrationTests
{
private const string ExampleYaml = """
name: main-task
mode: handler
language: typescript
trigger:
type: queue
stream: orders
entry: { file: index.ts, function: handler }
tasks:
- id: validate
parent: root
next: enrich
onError: compensate
language: python
entry: { file: validate.py }
- id: enrich
parent: root
next: persist
language: csharp
entry: { file: Enrich.cs }
- id: persist
parent: root
language: shell
entry: { file: persist.sh }
- id: compensate
parent: root
language: python
entry: { file: compensate.py }
""";
private readonly WorkflowsPostgresFixture _fixture;
public WorkflowSyncIntegrationTests(WorkflowsPostgresFixture fixture)
{
_fixture = fixture;
}
private WorkflowSyncService NewSync(string tenantId, FakeWorkflowSource source)
{
var compiler = new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry()));
return new WorkflowSyncService(
_fixture.CreateContext(), compiler, source, NullLogger<WorkflowSyncService>.Instance);
}
[Fact]
public async Task Sync_compiles_and_persists_workflow_with_task_tree()
{
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
var result = await NewSync(tenantId, source).SyncAsync(tenantId, default);
Assert.Equal(1, result.Compiled);
Assert.Empty(result.Errors);
await using var db = _fixture.CreateContext();
var workflow = await db.Workflows.Include(w => w.Tasks).SingleAsync(w => w.TenantId == tenantId);
Assert.Equal("main-task", workflow.Name);
Assert.Equal("compiled", workflow.Status);
Assert.Equal("abc123", workflow.GitSha);
Assert.Equal(5, workflow.Tasks.Count);
var root = workflow.Tasks.Single(t => t.Order == 0);
var validate = workflow.Tasks.Single(t => t.Order == 1);
Assert.Equal(validate.Id, root.NextId);
Assert.NotNull(validate.OnErrorId);
}
[Fact]
public async Task Sync_is_idempotent_across_repeat_syncs()
{
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
var sync = () => NewSync(tenantId, source).SyncAsync(tenantId, default);
await sync();
var second = await sync();
Assert.Equal(1, second.Compiled);
await using var db = _fixture.CreateContext();
Assert.Equal(1, await db.Workflows.CountAsync(w => w.TenantId == tenantId));
Assert.Equal(5, await db.Tasks.CountAsync(t => t.Workflow.TenantId == tenantId));
}
[Fact]
public async Task Sync_reconciles_tasks_when_chain_changes()
{
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
var source = new FakeWorkflowSource
{
["workflows/main-task.yaml"] = """
name: main-task
mode: function
language: shell
entry: { file: a.sh }
tasks:
- id: a
next: b
entry: { file: a.sh }
- id: b
entry: { file: b.sh }
""",
};
await NewSync(tenantId, source).SyncAsync(tenantId, default);
// Add a third task to the chain.
source["workflows/main-task.yaml"] = """
name: main-task
mode: function
language: shell
entry: { file: a.sh }
tasks:
- id: a
next: b
entry: { file: a.sh }
- id: b
next: c
entry: { file: b.sh }
- id: c
entry: { file: c.sh }
""";
await NewSync(tenantId, source).SyncAsync(tenantId, default);
await using var db = _fixture.CreateContext();
var workflow = await db.Workflows.Include(w => w.Tasks).SingleAsync(w => w.TenantId == tenantId);
Assert.Equal(4, workflow.Tasks.Count); // root + a + b + c
Assert.Contains(workflow.Tasks, t => t.Order == 3);
}
[Fact]
public async Task Sync_removes_workflow_when_file_deleted()
{
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
await NewSync(tenantId, source).SyncAsync(tenantId, default);
source.Clear();
var result = await NewSync(tenantId, source).SyncAsync(tenantId, default);
Assert.Equal(1, result.Removed);
await using var db = _fixture.CreateContext();
Assert.Equal(0, await db.Workflows.CountAsync(w => w.TenantId == tenantId));
}
[Fact]
public async Task Sync_removing_a_task_archives_it_and_preserves_its_taskruns()
{
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
await NewSync(tenantId, source).SyncAsync(tenantId, default);
// Give the 'enrich' task a historical run before it is removed from the YAML.
Guid enrichId;
await using (var db = _fixture.CreateContext())
{
var workflow = await db.Workflows.Include(w => w.Tasks).SingleAsync(w => w.TenantId == tenantId);
var enrich = workflow.Tasks.Single(t => t.Order == 2); // 'enrich' (csharp)
enrichId = enrich.Id;
var run = new WorkflowRun
{
Id = Guid.NewGuid(),
WorkflowId = workflow.Id,
TenantId = tenantId,
Status = RunStatus.Succeeded,
};
db.WorkflowRuns.Add(run);
await db.SaveChangesAsync();
db.TaskRuns.Add(new TaskRun
{
Id = Guid.NewGuid(),
RunId = run.Id,
TaskId = enrichId,
Attempt = 1,
Status = TaskRunStatus.Succeeded,
});
await db.SaveChangesAsync();
}
// Remove 'enrich' from the YAML (simulates a rename/removal of the task).
source["workflows/main-task.yaml"] = """
name: main-task
mode: handler
language: typescript
trigger:
type: queue
stream: orders
entry: { file: index.ts, function: handler }
tasks:
- id: validate
parent: root
next: persist
onError: compensate
language: python
entry: { file: validate.py }
- id: persist
parent: root
language: shell
entry: { file: persist.sh }
- id: compensate
parent: root
language: python
entry: { file: compensate.py }
""";
await NewSync(tenantId, source).SyncAsync(tenantId, default);
await using var after = _fixture.CreateContext();
// The historical TaskRun survives — sync no longer wipes it.
Assert.Equal(1, await after.TaskRuns.CountAsync(t => t.TaskId == enrichId));
// The removed task row is archived (soft-deleted), not deleted, and excluded
// from active-definition queries.
var archived = await after.Tasks.IgnoreQueryFilters().SingleAsync(t => t.Id == enrichId);
Assert.NotNull(archived.ArchivedAt);
Assert.False(await after.Tasks.AnyAsync(t => t.Id == enrichId));
}
[Fact]
public async Task Sync_reports_errors_and_skips_invalid_files()
{
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
var source = new FakeWorkflowSource
{
["workflows/broken.yaml"] = "name: broken\nmode: function\nlanguage: cobol\nentry: { file: x.sh }",
};
var result = await NewSync(tenantId, source).SyncAsync(tenantId, default);
Assert.Equal(0, result.Compiled);
Assert.Single(result.Errors);
Assert.Contains("not supported", result.Errors[0].Errors.Single());
await using var db = _fixture.CreateContext();
Assert.Equal(0, await db.Workflows.CountAsync(w => w.TenantId == tenantId));
}
private sealed class FakeWorkflowSource : IWorkflowSource
{
private readonly Dictionary<string, string> _files = new(StringComparer.Ordinal);
public string this[string path]
{
get => _files[path];
set => _files[path] = value;
}
public void Clear() => _files.Clear();
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<WorkflowFile>>(
_files.Keys.Select(p => new WorkflowFile(p)).OrderBy(p => p.Path, StringComparer.Ordinal).ToList());
public Task<string> ReadAsync(string path, CancellationToken ct)
=> Task.FromResult(_files[path]);
public WorkflowSourceState GetState() => new("abc123", false);
}
}