M3: Agent bound to a seat — configure an AI seat
OrgBoard: Agent entity (name, monogram, autonomy dial, ApiConfigId + optional fallback,
skill keys, docs) + AddAgents migration; one agent per seat. References Skills by key and
the BYOK config by id — never reaches into those modules.
Endpoints: POST/GET /api/orgboard/seats (create/list seats), POST/GET
/api/orgboard/seats/{id}/agent (configure/read the agent) — ConfigureAgents at [team, org].
Configuring an agent flips the seat to the AI state and points it at the agent; audited.
Verified: build green; ArchitectureTests 8/8; IntegrationTests 27/27 incl. the M3 acceptance
flow — owner adds a BYOK config, then configures "Aria" (gated autonomy, skills, that config)
on a seat, flipping it to AI, with the key never exposed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace TeamUp.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// M3 acceptance: an owner adds a BYOK config, then configures an AI seat ("Aria", gated autonomy,
|
||||
/// a skill, that config) — flipping the seat to AI — without the key ever being exposed.
|
||||
/// </summary>
|
||||
public sealed class SeatConfigTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||
{
|
||||
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
||||
|
||||
private sealed record OrganizationResponse(Guid Id, string Name);
|
||||
|
||||
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
|
||||
|
||||
private sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint);
|
||||
|
||||
private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
|
||||
|
||||
private sealed record AgentResponse(
|
||||
Guid Id, Guid SeatId, string Name, string? Monogram, string Autonomy,
|
||||
Guid ApiConfigId, Guid? FallbackApiConfigId, List<string> SkillKeys, List<string> Docs);
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_configures_an_ai_seat_with_skills_autonomy_and_byok_config()
|
||||
{
|
||||
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
|
||||
using var anon = factory.CreateClient();
|
||||
|
||||
var owner = await Bootstrap(anon);
|
||||
using var client = Authed(factory, owner.Token);
|
||||
|
||||
await PostOk<OrganizationResponse>(client, "/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<ApiConfigDto>(client, "/api/integrations/api-configs", new
|
||||
{
|
||||
organizationId = owner.OrganizationId,
|
||||
name = "Vertex-Pro",
|
||||
provider = "stub",
|
||||
model = "gemini-pro",
|
||||
apiKey = "sk-byok-secret",
|
||||
});
|
||||
|
||||
// Create an open seat, then configure an AI agent on it.
|
||||
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats",
|
||||
new { teamId = team.Id, roleName = "Product Owner" });
|
||||
Assert.Equal("Open", seat.State);
|
||||
|
||||
var agent = await PostOk<AgentResponse>(client, $"/api/orgboard/seats/{seat.Id}/agent", new
|
||||
{
|
||||
name = "Aria",
|
||||
monogram = "AR",
|
||||
autonomy = "Gated",
|
||||
apiConfigId = config.Id,
|
||||
skillKeys = new[] { "spec-writing", "story-breakdown" },
|
||||
docs = new[] { "product-docs" },
|
||||
});
|
||||
Assert.Equal("Aria", agent.Name);
|
||||
Assert.Equal("Gated", agent.Autonomy);
|
||||
Assert.Equal(config.Id, agent.ApiConfigId);
|
||||
Assert.Contains("spec-writing", agent.SkillKeys);
|
||||
|
||||
// Reading it back returns the same configuration.
|
||||
var fetched = await client.GetFromJsonAsync<AgentResponse>($"/api/orgboard/seats/{seat.Id}/agent");
|
||||
Assert.Equal(agent.Id, fetched!.Id);
|
||||
|
||||
// The seat is now an AI seat pointing at the agent.
|
||||
var seats = await client.GetFromJsonAsync<List<SeatResponse>>($"/api/orgboard/seats?teamId={team.Id}");
|
||||
var aiSeat = seats!.Single(s => s.Id == seat.Id);
|
||||
Assert.Equal("Ai", aiSeat.State);
|
||||
Assert.Equal(agent.Id, aiSeat.AgentId);
|
||||
}
|
||||
|
||||
private static async Task<BootstrapResponse> Bootstrap(HttpClient client)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/api/identity/bootstrap", new
|
||||
{
|
||||
organizationName = "AliaSaaS",
|
||||
ownerEmail = "owner@alia.test",
|
||||
ownerDisplayName = "Owner",
|
||||
ownerPassword = "Passw0rd!",
|
||||
});
|
||||
var owner = await response.Content.ReadFromJsonAsync<BootstrapResponse>();
|
||||
Assert.NotNull(owner);
|
||||
return owner!;
|
||||
}
|
||||
|
||||
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!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user