using System.Text.Json.Nodes; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Npgsql; using Nexus.Api.Data; using Nexus.Api.Models; using Nexus.Api.Services; using Testcontainers.PostgreSql; using Xunit; namespace Nexus.Api.Tests; [Collection(DockerIntegrationTestEnvironment.CollectionName)] public sealed class PostgreSqlAgentProvisioningIntegrationTests { private const string Category = "DockerIntegration"; [PostgreSqlIntegrationFact] [Trait("Category", Category)] public async Task Migrations_create_postgres_17_schema_and_required_indexes() { await using var postgres = BuildPostgreSql(); await postgres.StartAsync(); await using var db = CreateDatabase(postgres.GetConnectionString()); await db.Database.MigrateAsync(); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); Assert.Contains( "20260730224500_AddAgentProvisioningAndBoardIndexes", await db.Database.GetAppliedMigrationsAsync()); await using var connection = new NpgsqlConnection( postgres.GetConnectionString()); await connection.OpenAsync(); await using var versionCommand = new NpgsqlCommand( "SELECT current_setting('server_version_num')::integer", connection); var version = Convert.ToInt32(await versionCommand.ExecuteScalarAsync()); Assert.InRange(version, 170000, 179999); await using var tableCommand = new NpgsqlCommand( """ SELECT count(*) FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' AND relname IN ( 'AgentProposals', 'AgentProvisionRequests', 'OperationClaims', 'OutboxEvents') """, connection); Assert.Equal(4L, Convert.ToInt64(await tableCommand.ExecuteScalarAsync())); await using var indexCommand = new NpgsqlCommand( """ SELECT count(*) FROM pg_indexes WHERE schemaname = 'public' AND indexname IN ( 'IX_AgentProvisionRequests_Status_CreatedAt', 'IX_OperationClaims_Operation_IdempotencyKeyHash', 'IX_OutboxEvents_PublishedAt_Sequence', 'IX_Tasks_State_UpdatedAt_Id_Board', 'IX_Tasks_Done_UpdatedAt_Id', 'IX_Tasks_ParentTaskId_State', 'IX_Activity_TaskId_CreatedAt_Id') """, connection); Assert.Equal(7L, Convert.ToInt64(await indexCommand.ExecuteScalarAsync())); } [PostgreSqlIntegrationFact] [Trait("Category", Category)] public async Task Concurrent_proposal_creates_share_one_claim_and_one_outbox_event() { await using var postgres = BuildPostgreSql(); await postgres.StartAsync(); var connectionString = postgres.GetConnectionString(); var gateway = new ConcurrentApprovalGatewayConnector( synchronizeAgentLists: false); await MigrateAndSeedManagementAsync(connectionString, gateway); const string idempotencyKey = "postgres-concurrent-proposal"; var operations = Enumerable.Range(0, 8) .Select(_ => CreateProposalAsync( connectionString, gateway, idempotencyKey)) .ToArray(); var results = await Task.WhenAll(operations); Assert.All(results, result => Assert.True(result.Ok)); Assert.Single(results.Select(result => result.Proposal!.Id).Distinct()); Assert.Contains( results, result => result.State == AgentProposalStates.AwaitingApproval); Assert.Contains(results, result => result.State == "idempotent_replay"); await using var verification = CreateDatabase(connectionString); var proposal = await verification.AgentProposals.SingleAsync(); var claim = await verification.OperationClaims.SingleAsync(); var outbox = await verification.OutboxEvents.SingleAsync(); Assert.Equal(proposal.Id, claim.ResourceId); Assert.Equal("agent-proposal.create", claim.Operation); Assert.Equal("completed", claim.State); Assert.Equal(proposal.Id.ToString("N"), outbox.AggregateId); Assert.Equal("agent.proposal.created", outbox.Type); Assert.DoesNotContain( idempotencyKey, claim.IdempotencyKeyHash, StringComparison.Ordinal); await using var connection = new NpgsqlConnection(connectionString); await connection.OpenAsync(); await using var jsonTypeCommand = new NpgsqlCommand( """SELECT pg_typeof("PayloadJson")::text FROM "OutboxEvents" LIMIT 1""", connection); Assert.Equal("jsonb", await jsonTypeCommand.ExecuteScalarAsync()); } [PostgreSqlIntegrationFact] [Trait("Category", Category)] public async Task Concurrent_owner_approvals_queue_exactly_one_provision_request() { await using var postgres = BuildPostgreSql(); await postgres.StartAsync(); var connectionString = postgres.GetConnectionString(); var gateway = new ConcurrentApprovalGatewayConnector( synchronizeAgentLists: true); await MigrateAndSeedManagementAsync(connectionString, gateway); AgentProposalOperationDto created; await using (var creationDb = CreateDatabase(connectionString)) { created = await CreateService(creationDb, gateway).CreateAsync( Proposal(), "manual", Invocation("postgres-approval-proposal")); } var revision = created.Proposal!.Revision; var approvals = await Task.WhenAll( ApproveAsync( connectionString, gateway, created.Proposal.Id, revision, "postgres-approval-a"), ApproveAsync( connectionString, gateway, created.Proposal.Id, revision, "postgres-approval-b")); var succeeded = Assert.Single(approvals, result => result.Ok); var conflicted = Assert.Single(approvals, result => !result.Ok); Assert.Equal(AgentProposalStates.Provisioning, succeeded.State); Assert.Equal("concurrency_conflict", conflicted.State); await using var verification = CreateDatabase(connectionString); var proposal = await verification.AgentProposals.SingleAsync(); var request = await verification.AgentProvisionRequests.SingleAsync(); var claims = await verification.OperationClaims .OrderBy(item => item.CreatedAt) .ToArrayAsync(); var events = await verification.OutboxEvents .OrderBy(item => item.Sequence) .ToArrayAsync(); Assert.Equal(AgentProposalStates.Provisioning, proposal.Status); Assert.Equal(AgentProvisionRequestStates.Queued, request.Status); Assert.Equal(1, request.Attempt); Assert.Equal(3, claims.Length); Assert.Single( claims, item => item.Operation == "agent-proposal.approve" && item.State == "completed"); Assert.Single( claims, item => item.Operation == "agent-proposal.approve" && item.ResultCode == "concurrency_conflict"); Assert.Single( events, item => item.Type == "agent.provision.requested"); } private static PostgreSqlContainer BuildPostgreSql() => new PostgreSqlBuilder("postgres:17-alpine") .WithDatabase("nexus_integration") .WithUsername("nexus_test") .WithPassword("nexus_test_password") .Build(); private static NexusDbContext CreateDatabase(string connectionString) => new(new DbContextOptionsBuilder() .UseNpgsql(connectionString) .EnableDetailedErrors() .Options); private static async Task MigrateAndSeedManagementAsync( string connectionString, IGatewayConnector gateway) { await using var db = CreateDatabase(connectionString); await db.Database.MigrateAsync(); db.OpenClawConnectionProfiles.Add(new OpenClawConnectionProfile { Endpoint = "ws://127.0.0.1:18789/", DiscoverySource = "testcontainers", RequiredVersion = gateway.RequiredVersion, AdoptionState = OpenClawAdoptionStates.Adopted, ManagementEnabled = true, CapabilityHash = AgentProposalService.BuildCapabilityHash(gateway), Revision = 1 }); await db.SaveChangesAsync(); } private static async Task CreateProposalAsync( string connectionString, IGatewayConnector gateway, string idempotencyKey) { await using var db = CreateDatabase(connectionString); return await CreateService(db, gateway).CreateAsync( Proposal(), "manual", Invocation(idempotencyKey)); } private static async Task ApproveAsync( string connectionString, IGatewayConnector gateway, Guid proposalId, int expectedRevision, string idempotencyKey) { await using var db = CreateDatabase(connectionString); return await CreateService(db, gateway).ApproveAsync( proposalId, new AgentProposalActionRequest(expectedRevision), Invocation(idempotencyKey)); } internal static AgentProposalService CreateService( NexusDbContext db, IGatewayConnector gateway, IOpenClawAgentConfigurationService? agentFiles = null) { var management = new OpenClawManagementState(); management.SetEnabled(true); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["OpenClawSetup:ExternalClientIdentitySupported"] = "true" }) .Build(); return new AgentProposalService( db, gateway, agentFiles ?? new ProposalAgentConfigurationService(), new StubOpenClawWriteGate(), Options.Create(new AgentProvisioningOptions()), new AgentProvisioningSignal(), NullLogger.Instance); } internal static CreateAgentProposalRequest Proposal() => new( "Release Analyst", Role: "Release quality", Description: "Verify releases and report evidence.", Model: "openai/gpt-5.5", ClientRequestId: "postgres-proposal"); internal static OpenClawInvocationMetadata Invocation(string key) => new(key, $"correlation-{key}", "bao", null); } internal sealed class ConcurrentApprovalGatewayConnector( bool synchronizeAgentLists) : IGatewayConnector { private readonly TaskCompletionSource bothInventoryReads = new(TaskCreationOptions.RunContinuationsAsynchronously); private int inventoryReads; public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected; public string? GatewayVersion => "2026.7.1"; public string? RequiredVersion => "2026.7.1"; public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow; public int ReconnectAttempts => 0; public string? StatusMessage => "ready"; public string? DeviceId => "testcontainers-device"; public bool DeviceTokenConfigured => true; public bool PairingRequired => false; public string? PairingRequestId => null; public int? ProtocolVersion => 4; public IReadOnlySet AdvertisedMethods { get; } = new HashSet( [ "agents.list", "agents.create", "agents.files.get", "agents.files.set", "config.get" ], StringComparer.Ordinal); public IReadOnlySet AdvertisedEvents { get; } = new HashSet(StringComparer.Ordinal); public IReadOnlySet GrantedScopes { get; } = new HashSet( ["operator.read", "operator.admin"], StringComparer.Ordinal); public DateTimeOffset? LastEventAt => null; public bool Supports(string method) => AdvertisedMethods.Contains(method); public async Task InvokeAsync( string method, object? parameters = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { if (method != "agents.list") { throw new OpenClawGatewayRpcException( "METHOD_NOT_FOUND", $"Unexpected integration-test method {method}."); } if (synchronizeAgentLists) { if (Interlocked.Increment(ref inventoryReads) == 2) bothInventoryReads.TrySetResult(true); await bothInventoryReads.Task.WaitAsync( TimeSpan.FromSeconds(15), cancellationToken); } return new JsonObject { ["agents"] = new JsonArray() }; } public IReadOnlyList GetRecentEvents(int limit = 100) => []; }