Files
Teamup/tests/TeamUp.IntegrationTests/AnyRoleSeatTests.cs
T
soroush.asadi 4416d99360 Any seat can be AI-staffed: engineer/designer/analyst atoms + role-aware seat suggestions
The core product thesis made tangible beyond PO/QA:
- Four new golden-tested skill atoms in skills/: code-implementation + bug-diagnosis
  (engineer — output is a reviewable patch/diagnosis artifact; Git write-back stays Phase 2),
  ui-design-spec (designer), requirements-analysis (analyst, also tagged product-owner).
  The catalogue now spans five roles with eight atoms.
- Seat configurator: SuggestedSkills — maps the seat's free-text role name to skill role
  tags and offers the matching set one click ("Use set"). Any role name → staffed with AI.
- AnyRoleSeatTests: an "Backend Engineer" seat (Edison, gated) runs the same pipeline —
  skills assemble, implement-code/Draft parsed, proposal held in the review inbox like any
  governed action. SkillSyncTests updated for the larger catalogue.

Verified: IntegrationTests 44/44, client build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:57:10 +03:30

149 lines
6.2 KiB
C#

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 Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// The core product thesis: ANY seat can be AI-staffed — a role is just a name + skill atoms.
/// An "Engineer" seat (not PO, not QA) runs the same pipeline: skills assemble, the model is
/// called, and the implement-code proposal is held for review like any other governed action.
/// </summary>
public sealed class AnyRoleSeatTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
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<string> ChildTitles,
string? Trace, string Status, string? Decision, double? EditDistance, DateTimeOffset CreatedAtUtc);
[Fact]
public async Task An_engineer_seat_runs_the_same_governed_pipeline()
{
var settings = new Dictionary<string, string?>
{
["GitSource:Provider"] = "filesystem",
["GitSource:Root"] = LocateSkillsDirectory(),
};
await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings);
using var anon = factory.CreateClient();
var owner = await PostOk<BootstrapResponse>(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<TeamResponse>(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
var config = await PostOk<IdResponse>(client, "/api/integrations/api-configs", new
{
organizationId = owner.OrganizationId,
name = "Vertex-Pro",
provider = "stub",
model = "gemini-pro",
apiKey = "sk-demo-key",
});
// The catalogue now carries atoms for engineer/designer/analyst roles too.
var sync = await PostOk<SyncResult>(client, "/api/skills/sync", new { });
Assert.True(sync.Indexed >= 8);
// Staff an ENGINEER seat with AI — same configurator, different atoms.
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Backend Engineer" });
await client.PostAsJsonAsync($"/api/orgboard/seats/{seat.Id}/agent", new
{
name = "Edison",
monogram = "ED",
autonomy = "Gated",
apiConfigId = config.Id,
skillKeys = new[] { "code-implementation", "bug-diagnosis" },
docs = Array.Empty<string>(),
});
var task = await PostOk<RunTask>(client, "/api/orgboard/tasks", new
{
teamId = team.Id,
title = "Implement the logout endpoint",
description = "POST /logout clears the session.",
type = "Story",
});
var run = await PostOk<RunResponse>(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id });
await DrainOneJob(factory);
var done = await client.GetFromJsonAsync<RunResponse>($"/api/assembler/runs/{run.Id}");
Assert.Equal("Completed", done!.Status);
Assert.Equal("implement-code", done.ActionType); // the engineer atom's primary action
Assert.Equal("Draft", done.ActionRisk);
Assert.Contains("Code Implementation", done.Prompt); // the skill body assembled in
// Gated engineer output is governed exactly like PO/QA output: held for human review.
var pending = await client.GetFromJsonAsync<List<ReviewItemResponse>>(
$"/api/governance/reviews?organizationId={owner.OrganizationId}");
var held = Assert.Single(pending!);
Assert.Equal("implement-code", held.ActionKind);
Assert.Equal(task.Id, held.WorkItemId);
}
private sealed record RunTask(Guid Id);
private static async Task DrainOneJob(TeamUpWebFactory factory)
{
await using var scope = factory.Services.CreateAsyncScope();
var queue = scope.ServiceProvider.GetRequiredService<JobQueue>();
var job = await queue.ClaimNextAsync("test-worker");
Assert.NotNull(job);
await scope.ServiceProvider.GetRequiredService<AgentRunExecutor>().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<T> PostOk<T>(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<T>();
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");
}
}