Files
mic-check/src/api/MicCheck.Api/Organizations/OrganizationService.cs
James Wampler b4a666ebf0 Refactor nunit tests off EF InMemory provider and web server
Extract IMicCheckDbContext so DB access can be mocked with Moq instead
of the InMemory provider, per project testing guidelines. Add shared
async DbSet test doubles (TestSupport/) to back the mocks. Rewrite
HTTP-pipeline tests (WebApplicationFactory) to exercise controllers
and auth handlers directly instead of running the web server. Drop
DbContext/seeder tests that only made sense against a real provider,
and remove stale duplicate test files left over from a namespace move.
2026-07-04 19:54:22 -07:00

189 lines
6.7 KiB
C#
Executable File

using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Organizations;
public class OrganizationService(IMicCheckDbContext db, AuditService auditService)
{
public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default)
{
var rows = await (
from org in db.Organizations
join member in db.OrganizationUsers on org.Id equals member.OrganizationId
where member.UserId == userId
select new { org, member.IsPrimary }
).ToListAsync(ct);
return rows.Select(r => (r.org, r.IsPrimary)).ToList();
}
public async Task<Organization?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct);
}
public async Task<Organization> CreateAsync(string name, int creatorUserId, CancellationToken ct = default)
{
var hasExistingOrg = await db.OrganizationUsers.AnyAsync(ou => ou.UserId == creatorUserId, ct);
var org = new Organization
{
Name = name,
CreatedAt = DateTimeOffset.UtcNow
};
org.Members.Add(new OrganizationUser
{
UserId = creatorUserId,
Role = OrganizationRole.Admin,
IsPrimary = !hasExistingOrg
});
db.Organizations.Add(org);
await db.SaveChangesAsync(ct);
await auditService.LogAsync("Organization", org.Id.ToString(), "created", org.Id, ct: ct);
return org;
}
public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default)
{
var member = await db.OrganizationUsers
.FirstOrDefaultAsync(ou => ou.OrganizationId == organizationId && ou.UserId == userId, ct)
?? throw new KeyNotFoundException($"User is not a member of organization {organizationId}.");
await db.OrganizationUsers
.Where(ou => ou.UserId == userId && ou.IsPrimary)
.ExecuteUpdateAsync(s => s.SetProperty(ou => ou.IsPrimary, false), ct);
member.IsPrimary = true;
await db.SaveChangesAsync(ct);
}
public async Task<Organization> UpdateAsync(int id, string name, CancellationToken ct = default)
{
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct)
?? throw new KeyNotFoundException($"Organization {id} not found.");
org.Name = name;
await db.SaveChangesAsync(ct);
await auditService.LogAsync("Organization", org.Id.ToString(), "updated", org.Id, ct: ct);
return org;
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct);
if (org is null) return;
db.Organizations.Remove(org);
await db.SaveChangesAsync(ct);
}
public async Task<IReadOnlyList<OrganizationUser>> ListMembersAsync(int organizationId, CancellationToken ct = default)
{
return await db.OrganizationUsers
.Where(m => m.OrganizationId == organizationId)
.Include(m => m.User)
.ToListAsync(ct);
}
public async Task InviteUserAsync(int organizationId, int userId, OrganizationRole role, CancellationToken ct = default)
{
var existing = await db.OrganizationUsers
.FirstOrDefaultAsync(m => m.OrganizationId == organizationId && m.UserId == userId, ct);
if (existing is not null)
{
existing.Role = role;
}
else
{
db.OrganizationUsers.Add(new OrganizationUser
{
OrganizationId = organizationId,
UserId = userId,
Role = role
});
}
await db.SaveChangesAsync(ct);
}
public async Task RemoveMemberAsync(int organizationId, int userId, CancellationToken ct = default)
{
var member = await db.OrganizationUsers
.FirstOrDefaultAsync(m => m.OrganizationId == organizationId && m.UserId == userId, ct);
if (member is null) return;
db.OrganizationUsers.Remove(member);
await db.SaveChangesAsync(ct);
}
public async Task<string> GetOrCreateInviteTokenAsync(int organizationId, CancellationToken ct = default)
{
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == organizationId, ct)
?? throw new KeyNotFoundException($"Organization {organizationId} not found.");
if (org.InviteToken is null)
{
org.InviteToken = Guid.NewGuid().ToString("N");
await db.SaveChangesAsync(ct);
}
return org.InviteToken;
}
public async Task<string> RegenerateInviteTokenAsync(int organizationId, CancellationToken ct = default)
{
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == organizationId, ct)
?? throw new KeyNotFoundException($"Organization {organizationId} not found.");
org.InviteToken = Guid.NewGuid().ToString("N");
await db.SaveChangesAsync(ct);
return org.InviteToken;
}
public async Task<Organization?> FindByInviteTokenAsync(string token, CancellationToken ct = default) =>
await db.Organizations.FirstOrDefaultAsync(o => o.InviteToken == token, ct);
public async Task<IReadOnlyList<InviteByEmailResult>> InviteUsersByEmailAsync(int organizationId, IReadOnlyList<InviteByEmailEntry> invites, CancellationToken ct = default)
{
var results = new List<InviteByEmailResult>();
foreach (var invite in invites)
{
var user = await db.Users
.FirstOrDefaultAsync(u => u.Email == invite.Email.Trim().ToLower() && u.IsActive, ct);
if (user is null)
{
results.Add(new InviteByEmailResult(invite.Email, false, "No user found with that email address."));
continue;
}
if (!Enum.TryParse<OrganizationRole>(invite.Role, ignoreCase: true, out var role))
{
results.Add(new InviteByEmailResult(invite.Email, false, $"Invalid role '{invite.Role}'."));
continue;
}
await InviteUserAsync(organizationId, user.Id, role, ct);
results.Add(new InviteByEmailResult(invite.Email, true, null));
}
return results;
}
public async Task AcceptInviteAsync(string token, int userId, CancellationToken ct = default)
{
var org = await FindByInviteTokenAsync(token, ct)
?? throw new KeyNotFoundException("Invalid or expired invite link.");
await InviteUserAsync(org.Id, userId, OrganizationRole.User, ct);
}
}