1e65654114
The object spine becomes definable (data model was designed-for from day one):
- Division and Product entities (Product carries kind: Product|Service, optional DivisionId);
Team gains nullable ProductId — pre-structure teams keep working. AddDivisionsAndProducts
migration; org-scoped validation; owner-only writes (audited); list endpoints.
- /structure page: define divisions, products/services (with division), teams (under a
product). Org chart now renders the full spine — org → divisions → products → teams →
seats — with parentless layers linking up to the org.
- BYOK custom URL: the SeatsPage model-connection form gains a Base URL field (provider
list: stub/openai/ollama/vllm/custom). Backend already supported it end to end —
ApiConfig.Endpoint flows into the OpenAI-compatible adapter ({base}/v1/chat/completions),
so any OpenAI-compatible gateway or self-hosted model works; the config list shows it.
Verified: ArchitectureTests 8/8, IntegrationTests 45/45 (new OrgStructureTests: spine
creation, kind tags, org-scoped validation 400s, Member 403), client build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
119 lines
5.6 KiB
C#
119 lines
5.6 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using Xunit;
|
|
|
|
namespace TeamUp.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// The org structure spine: divisions → products/services → teams. Owner-only writes, org-scoped
|
|
/// validation, and teams optionally attached to a product (nullable for pre-structure teams).
|
|
/// </summary>
|
|
public sealed class OrgStructureTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
|
{
|
|
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 DivisionResponse(Guid Id, Guid OrganizationId, string Name);
|
|
|
|
private sealed record ProductResponse(Guid Id, Guid OrganizationId, Guid? DivisionId, string Name, string Kind);
|
|
|
|
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name, Guid? ProductId);
|
|
|
|
[Fact]
|
|
public async Task Divisions_products_and_teams_form_the_spine()
|
|
{
|
|
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
|
|
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" });
|
|
|
|
// Division → product under it; a service with no division; both listable.
|
|
var technical = await PostOk<DivisionResponse>(client, "/api/orgboard/divisions",
|
|
new { organizationId = owner.OrganizationId, name = "Technical" });
|
|
|
|
var ipnops = await PostOk<ProductResponse>(client, "/api/orgboard/products",
|
|
new { organizationId = owner.OrganizationId, name = "IPNOPS", kind = "Product", divisionId = technical.Id });
|
|
Assert.Equal(technical.Id, ipnops.DivisionId);
|
|
|
|
var payroll = await PostOk<ProductResponse>(client, "/api/orgboard/products",
|
|
new { organizationId = owner.OrganizationId, name = "Payroll", kind = "Service" });
|
|
Assert.Null(payroll.DivisionId);
|
|
Assert.Equal("Service", payroll.Kind);
|
|
|
|
var divisions = await client.GetFromJsonAsync<List<DivisionResponse>>(
|
|
$"/api/orgboard/divisions?organizationId={owner.OrganizationId}");
|
|
Assert.Contains(divisions!, d => d.Name == "Technical");
|
|
|
|
var products = await client.GetFromJsonAsync<List<ProductResponse>>(
|
|
$"/api/orgboard/products?organizationId={owner.OrganizationId}");
|
|
Assert.Equal(2, products!.Count);
|
|
|
|
// A team under the product; a team without one still works (backward compatible).
|
|
var coreTeam = await PostOk<TeamResponse>(client, "/api/orgboard/teams",
|
|
new { organizationId = owner.OrganizationId, name = "Core", productId = ipnops.Id });
|
|
Assert.Equal(ipnops.Id, coreTeam.ProductId);
|
|
|
|
var looseTeam = await PostOk<TeamResponse>(client, "/api/orgboard/teams",
|
|
new { organizationId = owner.OrganizationId, name = "Loose" });
|
|
Assert.Null(looseTeam.ProductId);
|
|
|
|
var teams = await client.GetFromJsonAsync<List<TeamResponse>>(
|
|
$"/api/orgboard/teams?organizationId={owner.OrganizationId}");
|
|
Assert.Contains(teams!, t => t.Id == coreTeam.Id && t.ProductId == ipnops.Id);
|
|
|
|
// Validation: a product can't attach to a foreign/unknown division; nor a team to an unknown product.
|
|
var badProduct = await client.PostAsJsonAsync("/api/orgboard/products",
|
|
new { organizationId = owner.OrganizationId, name = "X", kind = "Product", divisionId = Guid.NewGuid() });
|
|
Assert.Equal(HttpStatusCode.BadRequest, badProduct.StatusCode);
|
|
|
|
var badTeam = await client.PostAsJsonAsync("/api/orgboard/teams",
|
|
new { organizationId = owner.OrganizationId, name = "X", productId = Guid.NewGuid() });
|
|
Assert.Equal(HttpStatusCode.BadRequest, badTeam.StatusCode);
|
|
|
|
// A plain Member cannot create structure (owner capability).
|
|
var invite = await PostOk<InviteResponse>(client, "/api/identity/invitations", new
|
|
{
|
|
email = "dev@alia.test",
|
|
scopeType = "Organization",
|
|
scopeId = owner.OrganizationId,
|
|
role = "Member",
|
|
organizationId = owner.OrganizationId,
|
|
});
|
|
var member = await PostOk<AuthResponse>(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/orgboard/divisions",
|
|
new { organizationId = owner.OrganizationId, name = "Nope" });
|
|
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
|
}
|
|
|
|
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!;
|
|
}
|
|
}
|