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.
189 lines
6.7 KiB
C#
Executable File
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);
|
|
}
|
|
}
|