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.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, "workflows", 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, "workflows", 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, "workflows", 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, "workflows", 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, "workflows", default); source.Clear(); var result = await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", 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, "workflows", 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, "workflows", 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, "workflows", 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 _files = new(StringComparer.Ordinal); public string this[string path] { get => _files[path]; set => _files[path] = value; } public void Clear() => _files.Clear(); public Task> ListAsync(CancellationToken ct) => Task.FromResult>( _files.Keys.Select(p => new WorkflowFile(p)).OrderBy(p => p.Path, StringComparer.Ordinal).ToList()); public Task ReadAsync(string path, CancellationToken ct) => Task.FromResult(_files[path]); public WorkflowSourceState GetState() => new("abc123", false); } }