using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Xunit; namespace TeamUp.IntegrationTests; /// /// 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). /// public sealed class OrgStructureTests(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 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(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(client, "/api/orgboard/divisions", new { organizationId = owner.OrganizationId, name = "Technical" }); var ipnops = await PostOk(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(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>( $"/api/orgboard/divisions?organizationId={owner.OrganizationId}"); Assert.Contains(divisions!, d => d.Name == "Technical"); var products = await client.GetFromJsonAsync>( $"/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(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "Core", productId = ipnops.Id }); Assert.Equal(ipnops.Id, coreTeam.ProductId); var looseTeam = await PostOk(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "Loose" }); Assert.Null(looseTeam.ProductId); var teams = await client.GetFromJsonAsync>( $"/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(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/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 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!; } }