Files
Teamup/tests/TeamUp.IntegrationTests/OrgStructureTests.cs
T
soroush.asadi 1e65654114 Org structure: divisions → products/services → teams + custom model base URL
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>
2026-06-10 18:13:52 +03:30

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!;
}
}