using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Microsoft.Extensions.DependencyInjection; using TeamUp.Modules.Assembler.Queue; using TeamUp.Modules.Assembler.Runtime; using TeamUp.SharedKernel.Access; using TeamUp.SharedKernel.Ai; using Xunit; namespace TeamUp.IntegrationTests; /// /// M5 acceptance: Aria (gated) proposes a spec → it waits in the owner's review inbox with its /// trace → the owner edits and approves → the artifact lands and four child tasks appear on the /// board → edit distance is recorded. Plus: a Member cannot approve; an Autonomous agent's draft /// executes without review; destructive ALWAYS holds; send-back works. /// public sealed class ReviewFlowTests(PostgresFixture postgres) : IClassFixture { private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); private sealed record AuthResponse(string Token, Guid MemberId); private sealed record InviteResponse(Guid InvitationId, string Token); private sealed record IdResponse(Guid Id); private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name); private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId); private sealed record SyncResult(int Indexed); private sealed record RunResponse( Guid Id, Guid SeatId, Guid WorkItemId, Guid? AgentId, string Status, string? ActionType, string? ActionRisk, string? Prompt, string? Output, string? Error); private sealed record ReviewItemResponse( Guid Id, Guid OrganizationId, Guid TeamId, Guid AgentRunId, Guid AgentId, Guid WorkItemId, string ActionKind, string Risk, string Title, string Content, List ChildTitles, string? Trace, string Status, string? Decision, double? EditDistance, DateTimeOffset CreatedAtUtc); private sealed record TaskResponse( Guid Id, Guid TeamId, string Title, string? Description, string Type, string Status, string AssigneeKind, Guid? AssigneeId, Guid? ParentId); private sealed record BoardColumn(string Status, List Items); private sealed record BoardResponse(Guid TeamId, List Columns); private sealed record AuditEntryResponse( Guid Id, string Action, string EntityType, Guid EntityId, Guid? ActorMemberId, string? Details, DateTimeOffset OccurredAtUtc); [Fact] public async Task Gated_run_holds_for_review_then_edit_and_approve_lands_on_the_board() { var settings = new Dictionary { ["GitSource:Provider"] = "filesystem", ["GitSource:Root"] = LocateSkillsDirectory(), }; await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings); using var anon = factory.CreateClient(); // --- Setup: owner, org, team, stub BYOK config, skills, Aria (gated) on a seat --- var owner = await PostOk(anon, "/api/identity/bootstrap", new { organizationName = "AliaSaaS", ownerEmail = "owner@alia.test", ownerDisplayName = "Owner", ownerPassword = "Passw0rd!", }); using var client = Authed(factory, owner.Token); await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "AliaSaaS" }); var team = await PostOk(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" }); var config = await PostOk(client, "/api/integrations/api-configs", new { organizationId = owner.OrganizationId, name = "Vertex-Pro", provider = "stub", model = "gemini-pro", apiKey = "sk-demo-key", }); await PostOk(client, "/api/skills/sync", new { }); var seat = await PostOk(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Product Owner" }); await client.PostAsJsonAsync($"/api/orgboard/seats/{seat.Id}/agent", new { name = "Aria", monogram = "AR", autonomy = "Gated", apiConfigId = config.Id, skillKeys = new[] { "spec-writing", "story-breakdown" }, docs = Array.Empty(), }); var task = await PostOk(client, "/api/orgboard/tasks", new { teamId = team.Id, title = "Add a logout button to the header", description = "Users need a way to end their session.", type = "Spec", }); // --- Run Aria against the task; the worker drains it --- var run = await PostOk(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id }); await DrainOneJob(factory); var done = await client.GetFromJsonAsync($"/api/assembler/runs/{run.Id}"); Assert.Equal("Completed", done!.Status); // --- The gated draft is HELD: it waits in the review inbox with its trace --- var pending = await client.GetFromJsonAsync>( $"/api/governance/reviews?organizationId={owner.OrganizationId}"); var held = Assert.Single(pending!); Assert.Equal("Pending", held.Status); Assert.Equal("write-spec", held.ActionKind); Assert.Equal("Draft", held.Risk); Assert.Equal(task.Id, held.WorkItemId); Assert.False(string.IsNullOrWhiteSpace(held.Trace)); // the expandable reasoning trace // --- A plain Member cannot approve (owner/team-owner capability) --- var invite = await PostOk(client, "/api/identity/invitations", new { email = "dev@alia.test", scopeType = "Organization", scopeId = owner.OrganizationId, role = "Member", organizationId = owner.OrganizationId, }); var member = await PostOk(anon, "/api/identity/invitations/accept", new { token = invite.Token, displayName = "Dev", password = "Passw0rd!" }); using (var memberClient = Authed(factory, member.Token)) { var forbidden = await memberClient.PostAsJsonAsync($"/api/governance/reviews/{held.Id}/approve", new { }); Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode); } // --- Owner edits and approves: final spec + four child stories --- string[] children = [ "Add a logout button to the header (authenticated only)", "Clear the session and redirect to /login on click", "Hide the logout button when signed out", "Add a regression test for protected routes after logout", ]; var approved = await PostOk(client, $"/api/governance/reviews/{held.Id}/approve", new { content = "Spec: a logout button in the header that ends the session and returns to sign-in.", childTitles = children, }); Assert.Equal("Approved", approved.Status); Assert.Equal("EditedAndApproved", approved.Decision); Assert.NotNull(approved.EditDistance); Assert.True(approved.EditDistance > 0, "editing before approval must record a positive edit distance"); // --- The artifact + four child tasks landed on the board --- var board = await client.GetFromJsonAsync($"/api/orgboard/board?teamId={team.Id}"); var all = board!.Columns.SelectMany(c => c.Items).ToList(); var parent = all.Single(i => i.Id == task.Id); Assert.Contains("Spec: a logout button", parent.Description); var childTasks = all.Where(i => i.ParentId == task.Id).ToList(); Assert.Equal(4, childTasks.Count); Assert.All(childTasks, c => Assert.Equal("Story", c.Type)); // --- Deciding twice is rejected --- var again = await client.PostAsJsonAsync($"/api/governance/reviews/{held.Id}/approve", new { }); Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); // --- Audit recorded the hold and the edited approval (with the metric) --- var audit = await client.GetFromJsonAsync>( $"/api/governance/audit?organizationId={owner.OrganizationId}&take=200"); Assert.Contains(audit!, e => e.Action == "action.held" && e.EntityId == held.Id); Assert.Contains(audit!, e => e.Action == "review.edited-approved" && e.EntityId == held.Id && (e.Details ?? "").Contains("editDistance=")); // --- An Autonomous agent's draft executes without review --- var seatQa = await PostOk(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "QA" }); await client.PostAsJsonAsync($"/api/orgboard/seats/{seatQa.Id}/agent", new { name = "Quill", monogram = "QU", autonomy = "Autonomous", apiConfigId = config.Id, skillKeys = new[] { "test-plan-generation", "diff-review" }, docs = Array.Empty(), }); var qaTask = await PostOk(client, "/api/orgboard/tasks", new { teamId = team.Id, title = "QA the logout flow", description = (string?)null, type = "Test", }); await PostOk(client, "/api/assembler/runs", new { seatId = seatQa.Id, workItemId = qaTask.Id }); await DrainOneJob(factory); var pendingAfter = await client.GetFromJsonAsync>( $"/api/governance/reviews?organizationId={owner.OrganizationId}"); Assert.Empty(pendingAfter!); // nothing new held var boardAfter = await client.GetFromJsonAsync($"/api/orgboard/board?teamId={team.Id}"); var qaParent = boardAfter!.Columns.SelectMany(c => c.Items).Single(i => i.Id == qaTask.Id); Assert.Contains("[stub", qaParent.Description); // the artifact executed straight onto the task // --- Destructive ALWAYS holds, even for an Autonomous seat — and send-back works --- await using (var scope = factory.Services.CreateAsyncScope()) { var gate = scope.ServiceProvider.GetRequiredService(); var result = await gate.EvaluateAsync(new AgentActionProposal( Guid.NewGuid(), seatQa.Id, seatQa.AgentId ?? Guid.NewGuid(), qaTask.Id, team.Id, owner.OrganizationId, Autonomy.Autonomous, "delete-branch", "Destructive", "Delete the release branch", "rm -rf …", [], null)); Assert.Equal(GateOutcome.Held, result.Outcome); Assert.NotNull(result.ReviewItemId); var sentBack = await PostOk( client, $"/api/governance/reviews/{result.ReviewItemId}/sendback", new { }); Assert.Equal("SentBack", sentBack.Status); } } private static async Task DrainOneJob(TeamUpWebFactory factory) { await using var scope = factory.Services.CreateAsyncScope(); var queue = scope.ServiceProvider.GetRequiredService(); var job = await queue.ClaimNextAsync("test-worker"); Assert.NotNull(job); await scope.ServiceProvider.GetRequiredService().ProcessAsync(job!); } private static HttpClient Authed(TeamUpWebFactory factory, string token) { var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return client; } private static async Task PostOk(HttpClient client, string url, object body) { var response = await client.PostAsJsonAsync(url, body); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var value = await response.Content.ReadFromJsonAsync(); Assert.NotNull(value); return value!; } private static string LocateSkillsDirectory() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "TeamUp.slnx"))) { dir = dir.Parent; } Assert.NotNull(dir); return Path.Combine(dir!.FullName, "skills"); } }