Fix operations CI and snapshots
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s

This commit is contained in:
2026-06-14 09:14:24 +02:00
parent 5ea7aa9611
commit 45a39d319f
4 changed files with 154 additions and 12 deletions
+143
View File
@@ -0,0 +1,143 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public class OperationsSnapshotTests
{
[Fact]
public void GetSnapshot_RequiresAuthorization()
{
var method = typeof(OperationsController).GetMethod(nameof(OperationsController.GetSnapshot), BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(method);
Assert.NotNull(method!.GetCustomAttribute<AuthorizeAttribute>());
}
[Fact]
public async Task GetSnapshotAsync_DoesNotOverlapRepositoryReads()
{
var guard = new RepositoryConcurrencyGuard();
var runtime = new SnapshotRuntimeStub();
var agentService = new SnapshotAgentServiceStub();
var projectRepo = new GuardedProjectRepository(guard);
var taskRepo = new GuardedTaskRepository(guard);
var activityRepo = new GuardedActivityRepository(guard);
var service = new OperationsService(runtime, agentService, projectRepo, taskRepo, activityRepo);
await service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(1, guard.MaxConcurrentCalls);
}
}
internal sealed class RepositoryConcurrencyGuard
{
private readonly Lock sync = new();
private int currentCalls;
public int MaxConcurrentCalls { get; private set; }
public async Task<T> RunAsync<T>(T value, CancellationToken ct)
{
lock (sync)
{
currentCalls++;
MaxConcurrentCalls = Math.Max(MaxConcurrentCalls, currentCalls);
}
try
{
await Task.Delay(25, ct);
return value;
}
finally
{
lock (sync)
{
currentCalls--;
}
}
}
}
internal sealed class GuardedProjectRepository(RepositoryConcurrencyGuard guard) : IProjectRepository
{
public Task<List<Project>> GetAllAsync(CancellationToken ct = default)
=> guard.RunAsync(new List<Project>
{
new() { Name = "Alpha", Status = OperationalStatus.Online, Progress = 75 }
}, ct);
public ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<Project> AddAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task UpdateAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException();
}
internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository
{
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> guard.RunAsync(new List<WorkTask>
{
new() { Title = "Blocked task", State = TaskStateHelper.ToStateString(TaskState.Blocked), UpdatedAt = DateTimeOffset.UtcNow },
new() { Title = "Done task", State = TaskStateHelper.ToStateString(TaskState.Done), UpdatedAt = DateTimeOffset.UtcNow }
}, ct);
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException();
}
internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository
{
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> guard.RunAsync(new List<ActivityEvent>
{
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
}, ct);
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
=> throw new NotSupportedException();
}
internal sealed class SnapshotRuntimeStub : IAgentRuntime
{
public string Name => "stub";
public Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentRuntimeStatus("OpenClaw", OperationalStatus.Online, TimeSpan.FromMilliseconds(5), "ok"));
public Task<AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
internal sealed class SnapshotAgentServiceStub : IAgentService
{
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>(
[
new AgentInfo("iris", "Iris", "Orchestrator", "model", OperationalStatus.Online, DateTimeOffset.UtcNow, "/workspace", "ops")
]);
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}