feat: add vendor optional fields and configuration hooks
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation. - Created useCustomerConfig hook to load optional customer field settings from Sales configuration. - Developed useProcurementConfig hook to manage Procurement configuration flags. - Introduced accessConfigApi for managing access configuration settings. - Added openingBalancesApi for handling opening balances operations. - Created procurementConfigApi for managing procurement configuration settings. - Implemented nav-routes for permission-based routing. - Developed PDF generation for purchase orders with dynamic vendor and item details. - Added purchase-order tax helpers for tax calculations and formatting. - Defined types for opening balances and related operations.
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
using System.Security.Claims;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Dtos.Users;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The ERP-owned role model: AuthHex proves who someone is; the role, its permissions and "is admin" come from the
|
||||
/// ERP's own users/roles tables, are read on every request, and a change applies on the very next one.
|
||||
/// </summary>
|
||||
public class AccessControlTests
|
||||
{
|
||||
private const int AdminRole = 1, ApproverRole = 2, ViewerRole = 3, NoAccessRole = 4, InactiveRole = 5;
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
private sealed class FakeUser(int userId = 10, int? roleId = null, bool isAdmin = false) : ICurrentUser
|
||||
{
|
||||
public int AuditUserId { get; set; } = userId;
|
||||
public int? RoleId { get; set; } = roleId;
|
||||
public string? RoleCode { get; set; }
|
||||
public bool IsAdmin { get; set; } = isAdmin;
|
||||
public string UserId => AuditUserId.ToString();
|
||||
public bool IsAuthenticated => true;
|
||||
public int? BranchId => null;
|
||||
}
|
||||
|
||||
private static async Task<ErpDbContext> SeedAsync(bool withDefault = true)
|
||||
{
|
||||
var db = CreateContext();
|
||||
db.Roles.AddRange(
|
||||
new Role { RoleId = AdminRole, Code = "Admin", Name = "Administrator", IsAdmin = true, IsSystemRole = true, CreatedAt = DateTime.UtcNow },
|
||||
new Role { RoleId = ApproverRole, Code = "Approver", Name = "Approver", CreatedAt = DateTime.UtcNow },
|
||||
new Role { RoleId = ViewerRole, Code = "Viewer", Name = "Viewer", CreatedAt = DateTime.UtcNow },
|
||||
new Role { RoleId = NoAccessRole, Code = "NoAccess", Name = "No access", IsSystemRole = true, CreatedAt = DateTime.UtcNow },
|
||||
new Role { RoleId = InactiveRole, Code = "Retired", Name = "Retired", Status = EntityStatus.Inactive, CreatedAt = DateTime.UtcNow });
|
||||
|
||||
db.NavItems.Add(new NavItem { NavItemId = 100, Code = "procurement", Label = "Procurement" });
|
||||
db.SubNavItems.Add(new SubNavItem { SubNavItemId = 101, NavItemId = 100, Code = "procurement.vendors", Label = "Vendors" });
|
||||
db.NavItems.Add(new NavItem { NavItemId = 102, Code = "stock", Label = "Stock" });
|
||||
db.Permissions.AddRange(
|
||||
new Permission { PermissionId = 200, Code = "NAV:procurement", NavItemId = 100 },
|
||||
new Permission { PermissionId = 201, Code = "NAV:procurement.vendors", SubNavItemId = 101 },
|
||||
new Permission { PermissionId = 202, Code = "NAV:stock", NavItemId = 102 });
|
||||
db.RolePermissions.AddRange(
|
||||
new RolePermission { RoleId = ApproverRole, PermissionId = 200, Actions = RolePermissionAction.View | RolePermissionAction.Approve },
|
||||
new RolePermission { RoleId = ViewerRole, PermissionId = 201, Actions = RolePermissionAction.View },
|
||||
new RolePermission { RoleId = InactiveRole, PermissionId = 202, Actions = RolePermissionAction.All });
|
||||
|
||||
if (withDefault) db.AccessConfig.Add(new AccessConfig { ConfigId = AccessConfig.SingletonId, DefaultRoleId = NoAccessRole });
|
||||
await db.SaveChangesAsync();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Principal(Guid authUserId, string? tokenRoleCode = null)
|
||||
{
|
||||
var claims = new List<Claim> { new("UserId", authUserId.ToString()), new("NIC", "N-1") };
|
||||
if (tokenRoleCode is not null) claims.Add(new Claim("RoleCode", tokenRoleCode));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
}
|
||||
|
||||
private static ShadowUserClaimsTransformation Transformer(ErpDbContext db, bool legacyFallback = true) =>
|
||||
new(db, new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:LegacyRoleClaimFallback"] = legacyFallback.ToString() })
|
||||
.Build());
|
||||
|
||||
private static string? Claim(ClaimsPrincipal p, string type) => p.FindFirstValue(type);
|
||||
|
||||
private static User NewUser(Guid authId, int? roleId, EntityStatus status = EntityStatus.Active, int id = 0) => new()
|
||||
{
|
||||
UserId = id == 0 ? Random.Shared.Next(100, 100000) : id,
|
||||
AuthUserId = authId, Username = $"u-{authId:N}", DisplayName = "Someone", RoleId = roleId, Status = status
|
||||
};
|
||||
|
||||
private static NavPermissionService NavService(ErpDbContext db) =>
|
||||
new(new Repository<Role>(db), new Repository<RolePermission>(db), new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
private static RoleService RoleSvc(ErpDbContext db, ICurrentUser user, NavPermissionService nav) =>
|
||||
new(new Repository<Role>(db), new Repository<RolePermission>(db), new Repository<Permission>(db), new Repository<Employee>(db),
|
||||
new Repository<User>(db), new Repository<AccessConfig>(db), user, nav, new FakeBranchConfig(), new PassThroughUnitOfWork(db));
|
||||
|
||||
// The AuthHex client, link and employee services are not touched by role/status changes (users here have no AuthUserId).
|
||||
private static UserManagementService UserSvc(ErpDbContext db, ICurrentUser user) =>
|
||||
new(new Repository<User>(db), new Repository<Role>(db), new Repository<Employee>(db), null!, null!, null!, user, new PassThroughUnitOfWork(db));
|
||||
|
||||
// ---------------------------------------------------------------- claims: the role comes from the ERP
|
||||
|
||||
[Fact]
|
||||
public async Task Role_IsReadFromTheErpTable_NotFromTheTokensRoleClaim()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var auth = Guid.NewGuid();
|
||||
db.Users.Add(NewUser(auth, ApproverRole));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// The token still says "Admin" (an old AuthHex token). The ERP says Approver, and the ERP wins.
|
||||
var principal = await Transformer(db).TransformAsync(Principal(auth, tokenRoleCode: "Admin"));
|
||||
|
||||
Assert.Equal(ApproverRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
|
||||
Assert.Equal("Approver", Claim(principal, AuthHexClaims.ErpRoleCode));
|
||||
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_IsTheRoleFlag_NotTheRoleName()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var boss = Guid.NewGuid();
|
||||
var impostor = Guid.NewGuid();
|
||||
db.Roles.Add(new Role { RoleId = 20, Code = "Boss", Name = "The boss", IsAdmin = true, CreatedAt = DateTime.UtcNow });
|
||||
db.Roles.Add(new Role { RoleId = 21, Code = "ADMIN2", Name = "admin", CreatedAt = DateTime.UtcNow }); // named like an admin, is not
|
||||
db.Users.AddRange(NewUser(boss, 20), NewUser(impostor, 21));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Assert.Equal("true", Claim(await Transformer(db).TransformAsync(Principal(boss)), AuthHexClaims.ErpIsAdmin));
|
||||
Assert.Equal("false", Claim(await Transformer(db).TransformAsync(Principal(impostor)), AuthHexClaims.ErpIsAdmin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenamingTheAdminRole_DoesNotCostAnyoneTheirAdminRights()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var auth = Guid.NewGuid();
|
||||
db.Users.Add(NewUser(auth, AdminRole));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Roles.Single(r => r.RoleId == AdminRole).Code = "Owners";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var principal = await Transformer(db).TransformAsync(Principal(auth));
|
||||
Assert.Equal("true", Claim(principal, AuthHexClaims.ErpIsAdmin));
|
||||
Assert.Equal("Owners", Claim(principal, AuthHexClaims.ErpRoleCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirstSignIn_CreatesTheUser_AndGivesItTheDefaultRole()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var auth = Guid.NewGuid();
|
||||
|
||||
var principal = await Transformer(db).TransformAsync(Principal(auth));
|
||||
|
||||
var user = Assert.Single(db.Users.Where(u => u.AuthUserId == auth));
|
||||
Assert.Equal(NoAccessRole, user.RoleId); // saved, so the Users screen shows it
|
||||
Assert.Equal(NoAccessRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
|
||||
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
|
||||
Assert.Equal(user.UserId.ToString(), Claim(principal, ClaimTypes.NameIdentifier));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithNoDefaultRole_APersonWithoutARoleHasNoRoleClaims()
|
||||
{
|
||||
await using var db = await SeedAsync(withDefault: false);
|
||||
var principal = await Transformer(db).TransformAsync(Principal(Guid.NewGuid()));
|
||||
|
||||
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
|
||||
Assert.Null(Claim(principal, AuthHexClaims.ErpIsAdmin));
|
||||
Assert.NotNull(Claim(principal, ClaimTypes.NameIdentifier)); // still a known user
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyFallback_UsesTheTokensRole_ForARolelessUser_AndSavesIt()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var auth = Guid.NewGuid();
|
||||
db.Users.Add(NewUser(auth, roleId: null));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var principal = await Transformer(db, legacyFallback: true).TransformAsync(Principal(auth, "Approver"));
|
||||
|
||||
Assert.Equal(ApproverRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
|
||||
Assert.Equal(ApproverRole, db.Users.Single(u => u.AuthUserId == auth).RoleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyFallbackOff_IgnoresTheTokenAndUsesTheDefault()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var auth = Guid.NewGuid();
|
||||
db.Users.Add(NewUser(auth, roleId: null));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var principal = await Transformer(db, legacyFallback: false).TransformAsync(Principal(auth, "Admin"));
|
||||
|
||||
Assert.Equal(NoAccessRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
|
||||
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnAssignedRole_IsNeverOverwrittenByTheFallbackOrTheDefault()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var auth = Guid.NewGuid();
|
||||
db.Users.Add(NewUser(auth, ViewerRole));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var principal = await Transformer(db).TransformAsync(Principal(auth, "Admin"));
|
||||
|
||||
Assert.Equal(ViewerRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
|
||||
Assert.Equal(ViewerRole, db.Users.Single(u => u.AuthUserId == auth).RoleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactiveUser_IsBlocked_AndGetsNoRole()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var auth = Guid.NewGuid();
|
||||
db.Users.Add(NewUser(auth, AdminRole, EntityStatus.Inactive));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var principal = await Transformer(db).TransformAsync(Principal(auth));
|
||||
|
||||
Assert.Equal("1", Claim(principal, AuthHexClaims.ErpBlocked));
|
||||
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
|
||||
Assert.Null(Claim(principal, AuthHexClaims.ErpIsAdmin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactiveRole_GrantsNothing()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var auth = Guid.NewGuid();
|
||||
db.Users.Add(NewUser(auth, InactiveRole));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var principal = await Transformer(db).TransformAsync(Principal(auth));
|
||||
|
||||
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
|
||||
Assert.Null(Claim(principal, AuthHexClaims.ErpBlocked)); // not blocked — just no permissions
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Branch_ComesFromTheLinkedEmployee_AndTransformingTwiceAddsNothingNew()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var auth = Guid.NewGuid();
|
||||
var user = NewUser(auth, ViewerRole, id: 500);
|
||||
db.Users.Add(user);
|
||||
db.Employees.Add(new Employee { EmployeeId = 1, EmployeeCode = "E1", FullName = "Emp", UserId = 500, BranchId = Branch2 });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var transformer = Transformer(db);
|
||||
var principal = await transformer.TransformAsync(Principal(auth));
|
||||
var claimsAfterFirst = principal.Claims.Count();
|
||||
principal = await transformer.TransformAsync(principal);
|
||||
|
||||
Assert.Equal(Branch2.ToString(), Claim(principal, AuthHexClaims.BranchId));
|
||||
Assert.Equal(claimsAfterFirst, principal.Claims.Count());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- permissions by role id
|
||||
|
||||
[Fact]
|
||||
public async Task Nav_AccessAndActions_AreDecidedByTheRolesPermissions()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var nav = NavService(db);
|
||||
|
||||
Assert.True(await nav.HasAccessAsync(ApproverRole, "procurement"));
|
||||
Assert.True(await nav.HasActionAsync(ApproverRole, "procurement", RolePermissionAction.Approve));
|
||||
Assert.False(await nav.HasActionAsync(ApproverRole, "procurement", RolePermissionAction.Delete));
|
||||
Assert.False(await nav.HasAccessAsync(ApproverRole, "stock"));
|
||||
|
||||
// A parent module is reachable through a child grant; an action is exact.
|
||||
Assert.True(await nav.HasAccessAsync(ViewerRole, "procurement"));
|
||||
Assert.False(await nav.HasActionAsync(ViewerRole, "procurement", RolePermissionAction.View));
|
||||
Assert.True(await nav.HasActionAsync(ViewerRole, "procurement.vendors", RolePermissionAction.View));
|
||||
|
||||
Assert.False(await nav.HasAccessAsync(null, "procurement"));
|
||||
Assert.False(await nav.HasAccessAsync(999, "procurement"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nav_InactiveRole_HasNoAccess()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
Assert.False(await NavService(db).HasAccessAsync(InactiveRole, "stock"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditingARolesPermissions_AppliesImmediately_NotAfterACacheTimeout()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var nav = NavService(db);
|
||||
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), nav);
|
||||
|
||||
Assert.False(await nav.HasAccessAsync(ViewerRole, "stock")); // cached as "no"
|
||||
await roles.AssignPermissionsAsync(ViewerRole, new Dtos.Rbac.AssignRolePermissionsRequest { NavItemIds = [102] });
|
||||
|
||||
Assert.True(await nav.HasAccessAsync(ViewerRole, "stock")); // visible straight away
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- /auth/me
|
||||
|
||||
[Fact]
|
||||
public async Task Me_ForAnAdministrator_ListsEveryPermission_EvenOnesNeverGranted()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var me = await RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db)).GetMeAsync();
|
||||
|
||||
Assert.True(me.HasRole);
|
||||
Assert.True(me.IsAdmin);
|
||||
Assert.Equal(AdminRole, me.RoleId);
|
||||
Assert.Contains("procurement.vendors", me.NavCodes);
|
||||
Assert.Contains("stock", me.NavCodes);
|
||||
Assert.Equal((int)RolePermissionAction.All, me.Permissions!["stock"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Me_ForAnOrdinaryRole_ListsOnlyItsGrants_WithTheirActions()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var me = await RoleSvc(db, new FakeUser(roleId: ApproverRole), NavService(db)).GetMeAsync();
|
||||
|
||||
Assert.True(me.HasRole);
|
||||
Assert.False(me.IsAdmin);
|
||||
Assert.Equal(["procurement"], me.NavCodes);
|
||||
Assert.Equal((int)(RolePermissionAction.View | RolePermissionAction.Approve), me.Permissions!["procurement"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Me_WithoutARole_SaysSo_InsteadOfReturningAnEmptyMenu()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var me = await RoleSvc(db, new FakeUser(roleId: null), NavService(db)).GetMeAsync();
|
||||
|
||||
Assert.False(me.HasRole);
|
||||
Assert.Empty(me.NavCodes);
|
||||
Assert.Empty(me.Permissions!);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- role management guards
|
||||
|
||||
[Fact]
|
||||
public async Task TheAdministratorSystemAndDefaultRoles_CannotBeDeactivatedOrDeleted()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
|
||||
|
||||
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(AdminRole, EntityStatus.Inactive));
|
||||
await Assert.ThrowsAsync<DomainException>(() => roles.DeleteAsync(AdminRole));
|
||||
await Assert.ThrowsAsync<DomainException>(() => roles.DeleteAsync(NoAccessRole)); // system role and the default
|
||||
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(NoAccessRole, EntityStatus.Inactive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARoleInUse_CannotBeDeactivated_ButAnUnusedOneCanBeDeleted()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
db.Users.Add(NewUser(Guid.NewGuid(), ViewerRole));
|
||||
await db.SaveChangesAsync();
|
||||
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
|
||||
|
||||
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(ViewerRole, EntityStatus.Inactive));
|
||||
await roles.DeleteAsync(ApproverRole);
|
||||
Assert.False(db.Roles.Any(r => r.RoleId == ApproverRole));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatedRoles_NeverCarryTheAdminFlag()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
|
||||
|
||||
var created = (await roles.CreateAsync(new Dtos.Rbac.CreateRoleRequest { Code = "Clerk", Name = "Clerk" })).Value;
|
||||
|
||||
Assert.False(created.IsAdmin);
|
||||
Assert.False(created.IsSystemRole);
|
||||
Assert.Null(db.Roles.Single(r => r.RoleId == created.RoleId).AuthRoleId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- user management guards
|
||||
|
||||
[Fact]
|
||||
public async Task ChangingAUsersRole_TakesEffectOnTheirNextRequest_WithNoRelogin()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var auth = Guid.NewGuid();
|
||||
var user = NewUser(auth, ViewerRole, id: 700);
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
Assert.Equal(ViewerRole.ToString(), Claim(await Transformer(db).TransformAsync(Principal(auth)), AuthHexClaims.ErpRoleId));
|
||||
|
||||
await UserSvc(db, new FakeUser(1, AdminRole, true)).UpdateRoleAsync(700, new UpdateUserRoleRequest { RoleId = ApproverRole });
|
||||
|
||||
// A fresh request for the same login now carries the new role.
|
||||
Assert.Equal(ApproverRole.ToString(), Claim(await Transformer(db).TransformAsync(Principal(auth)), AuthHexClaims.ErpRoleId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheLastActiveAdministrator_CannotBeDemotedOrDeactivated()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
db.Users.Add(NewUser(Guid.NewGuid(), AdminRole, id: 801));
|
||||
await db.SaveChangesAsync();
|
||||
var svc = UserSvc(db, new FakeUser(userId: 1, roleId: AdminRole, isAdmin: true));
|
||||
|
||||
var demote = await Assert.ThrowsAsync<DomainException>(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }));
|
||||
var deactivate = await Assert.ThrowsAsync<DomainException>(() => svc.SetStatusAsync(801, EntityStatus.Inactive));
|
||||
|
||||
Assert.Equal(409, demote.StatusCode);
|
||||
Assert.Equal(409, deactivate.StatusCode);
|
||||
Assert.Equal(AdminRole, db.Users.Single(u => u.UserId == 801).RoleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnAdministrator_CanBeDemoted_WhenAnotherActiveAdminRemains()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
db.Users.AddRange(NewUser(Guid.NewGuid(), AdminRole, id: 801), NewUser(Guid.NewGuid(), AdminRole, id: 802));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await UserSvc(db, new FakeUser(userId: 802, roleId: AdminRole, isAdmin: true))
|
||||
.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole });
|
||||
|
||||
Assert.Equal(ViewerRole, db.Users.Single(u => u.UserId == 801).RoleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nobody_CanRemoveTheirOwnAdminAccess_OrDeactivateThemselves()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
db.Users.AddRange(NewUser(Guid.NewGuid(), AdminRole, id: 801), NewUser(Guid.NewGuid(), AdminRole, id: 802));
|
||||
await db.SaveChangesAsync();
|
||||
var svc = UserSvc(db, new FakeUser(userId: 801, roleId: AdminRole, isAdmin: true));
|
||||
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }));
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.SetStatusAsync(801, EntityStatus.Inactive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AUser_CannotBeGivenAnInactiveRole()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
db.Users.Add(NewUser(Guid.NewGuid(), ViewerRole, id: 900));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await Assert.ThrowsAsync<DomainException>(
|
||||
() => UserSvc(db, new FakeUser(1, AdminRole, true)).UpdateRoleAsync(900, new UpdateUserRoleRequest { RoleId = InactiveRole }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- access config (default role)
|
||||
|
||||
private static AccessConfigService AccessSvc(ErpDbContext db) =>
|
||||
new(new Repository<AccessConfig>(db), new Repository<Role>(db), new FakeUser(), new PassThroughUnitOfWork(db));
|
||||
|
||||
[Fact]
|
||||
public async Task TheDefaultRole_CanNeverBeAnAdministratorOrAnInactiveRole()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var svc = AccessSvc(db);
|
||||
var etag = db.AccessConfig.Single().RowVersion;
|
||||
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = AdminRole }, etag));
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = InactiveRole }, etag));
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = 999 }, etag));
|
||||
Assert.Equal(NoAccessRole, db.AccessConfig.Single().DefaultRoleId);
|
||||
|
||||
var changed = (await svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = ViewerRole }, etag)).Value;
|
||||
Assert.Equal(("Viewer", ViewerRole), (changed.DefaultRoleName, changed.DefaultRoleId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- seeding
|
||||
|
||||
[Fact]
|
||||
public async Task Seeder_EnsuresAnAdminRoleAndALeastPrivilegeDefault_AndIsIdempotent()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
await DataSeeder.SeedAsync(db);
|
||||
await DataSeeder.SeedAsync(db);
|
||||
|
||||
var admin = Assert.Single(db.Roles.Where(r => r.IsAdmin));
|
||||
Assert.True(admin.IsSystemRole);
|
||||
Assert.Equal(db.Permissions.Count(), db.RolePermissions.Count(rp => rp.RoleId == admin.RoleId));
|
||||
var noAccess = Assert.Single(db.Roles.Where(r => r.Code == DataSeeder.DefaultNoAccessRoleCode));
|
||||
Assert.Empty(db.RolePermissions.Where(rp => rp.RoleId == noAccess.RoleId));
|
||||
Assert.Equal(noAccess.RoleId, Assert.Single(db.AccessConfig).DefaultRoleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Seeder_FlagsAnExistingRoleCalledAdmin_InsteadOfAddingASecond_AndKeepsAChosenDefault()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.Database.EnsureCreated();
|
||||
db.Roles.Add(new Role { RoleId = 50, Code = "ADMIN", Name = "Old admin", CreatedAt = DateTime.UtcNow });
|
||||
db.Roles.Add(new Role { RoleId = 51, Code = "Reader", Name = "Reader", CreatedAt = DateTime.UtcNow });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await DataSeeder.SeedAsync(db);
|
||||
db.AccessConfig.Single().DefaultRoleId = 51; // an administrator's own choice
|
||||
await db.SaveChangesAsync();
|
||||
await DataSeeder.SeedAsync(db);
|
||||
|
||||
Assert.True(db.Roles.Single(r => r.RoleId == 50).IsAdmin);
|
||||
Assert.Equal(1, db.Roles.Count(r => r.IsAdmin));
|
||||
Assert.Equal(51, db.AccessConfig.Single().DefaultRoleId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Customers;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Sales Configuration decides which optional customer fields exist (phone, email, address, city, country, credit limit,
|
||||
/// credit days). The forms hide a switched-off field; the server refuses a new value for it and keeps what is stored.
|
||||
/// </summary>
|
||||
public class CustomerFieldSwitchTests
|
||||
{
|
||||
private static CustomerService Svc(ErpDbContext db) =>
|
||||
new(new Repository<Customer>(db), new Repository<Warehouse>(db), new FakeBranchConfig(),
|
||||
new Repository<SalesConfig>(db), new Repository<SalesInvoice>(db), new Repository<SalesInvoicePayment>(db),
|
||||
new PassThroughUnitOfWork(db));
|
||||
|
||||
private static async Task SwitchesAsync(ErpDbContext db, Action<SalesConfig> tweak)
|
||||
{
|
||||
var config = new SalesConfig { ConfigId = SalesConfig.SingletonId };
|
||||
tweak(config);
|
||||
db.SalesConfig.Add(config);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static CreateCustomerRequest NewCustomer(Action<CreateCustomerRequest>? tweak = null)
|
||||
{
|
||||
var request = new CreateCustomerRequest { CustomerCode = "C-1", Name = "Nimal Perera", BranchId = Branch1 };
|
||||
tweak?.Invoke(request);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static UpdateCustomerRequest Same(Customer c, Action<UpdateCustomerRequest>? tweak = null)
|
||||
{
|
||||
var request = new UpdateCustomerRequest
|
||||
{
|
||||
CustomerCode = c.CustomerCode, CustomerType = c.CustomerType, Name = c.Name, DisplayName = c.DisplayName,
|
||||
Phone = c.Phone, Email = c.Email, AddressLine1 = c.AddressLine1, AddressLine2 = c.AddressLine2,
|
||||
City = c.City, Country = c.Country, TaxRegistrationNo = c.TaxRegistrationNo,
|
||||
CreditLimit = c.CreditLimit, CreditDays = c.CreditDays, BranchId = c.BranchId
|
||||
};
|
||||
tweak?.Invoke(request);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryFieldIsOn_WhenNothingIsConfigured_SoExistingBehaviourIsUnchanged()
|
||||
{
|
||||
await using var db = CreateContext(); // no sales_config row at all
|
||||
|
||||
var created = (await Svc(db).CreateAsync(NewCustomer(r =>
|
||||
{
|
||||
r.Phone = "0771234567"; r.Email = "a@b.lk"; r.AddressLine1 = "No. 12"; r.City = "Colombo";
|
||||
r.Country = "Sri Lanka"; r.CreditLimit = 5000m; r.CreditDays = 30;
|
||||
}))).Value;
|
||||
|
||||
Assert.Equal(("0771234567", "a@b.lk", "No. 12", "Colombo", "Sri Lanka"), (created.Phone, created.Email, created.AddressLine1, created.City, created.Country));
|
||||
Assert.Equal((5000m, 30), (created.CreditLimit, created.CreditDays));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("phone")]
|
||||
[InlineData("email")]
|
||||
[InlineData("address")]
|
||||
[InlineData("city")]
|
||||
[InlineData("country")]
|
||||
[InlineData("creditLimit")]
|
||||
[InlineData("creditDays")]
|
||||
public async Task Create_ANewValueForASwitchedOffField_IsRefused(string field)
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SwitchesAsync(db, c =>
|
||||
{
|
||||
c.CustomerPhoneEnabled = field != "phone";
|
||||
c.CustomerEmailEnabled = field != "email";
|
||||
c.CustomerAddressEnabled = field != "address";
|
||||
c.CustomerCityEnabled = field != "city";
|
||||
c.CustomerCountryEnabled = field != "country";
|
||||
c.CustomerCreditLimitEnabled = field != "creditLimit";
|
||||
c.CustomerCreditDaysEnabled = field != "creditDays";
|
||||
});
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => Svc(db).CreateAsync(NewCustomer(r =>
|
||||
{
|
||||
switch (field)
|
||||
{
|
||||
case "phone": r.Phone = "0771234567"; break;
|
||||
case "email": r.Email = "a@b.lk"; break;
|
||||
case "address": r.AddressLine2 = "Wellawatte"; break;
|
||||
case "city": r.City = "Colombo"; break;
|
||||
case "country": r.Country = "Sri Lanka"; break;
|
||||
case "creditLimit": r.CreditLimit = 100m; break;
|
||||
default: r.CreditDays = 7; break;
|
||||
}
|
||||
})));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
|
||||
Assert.Equal(422, ex.StatusCode);
|
||||
Assert.Empty(db.Customers.Where(c => c.CustomerCode == "C-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithTheSwitchedOffFieldsLeftBlank_Works()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SwitchesAsync(db, c =>
|
||||
{
|
||||
c.CustomerPhoneEnabled = c.CustomerEmailEnabled = c.CustomerAddressEnabled = false;
|
||||
c.CustomerCityEnabled = c.CustomerCountryEnabled = false;
|
||||
c.CustomerCreditLimitEnabled = c.CustomerCreditDaysEnabled = false;
|
||||
});
|
||||
|
||||
var created = (await Svc(db).CreateAsync(NewCustomer())).Value;
|
||||
|
||||
Assert.Equal("Nimal Perera", created.Name);
|
||||
Assert.Null(created.Phone);
|
||||
Assert.Equal((0m, 0), (created.CreditLimit, created.CreditDays));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_KeepsAStoredValueWhoseFieldWasSwitchedOff_EvenWhenTheFormSendsItBlank()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var svc = Svc(db);
|
||||
var created = (await svc.CreateAsync(NewCustomer(r => { r.Phone = "0771234567"; r.CreditLimit = 5000m; r.CreditDays = 30; }))).Value;
|
||||
|
||||
await SwitchesAsync(db, c => { c.CustomerPhoneEnabled = false; c.CustomerCreditLimitEnabled = false; c.CustomerCreditDaysEnabled = false; });
|
||||
var stored = db.Customers.Single(c => c.CustomerId == created.CustomerId);
|
||||
|
||||
// The edit form no longer has the fields, so it sends blanks / zeros.
|
||||
var updated = (await svc.UpdateAsync(created.CustomerId,
|
||||
Same(stored, r => { r.Name = "Nimal P."; r.Phone = null; r.CreditLimit = 0m; r.CreditDays = 0; }), stored.RowVersion)).Value;
|
||||
|
||||
Assert.Equal("Nimal P.", updated.Name);
|
||||
Assert.Equal("0771234567", updated.Phone);
|
||||
Assert.Equal((5000m, 30), (updated.CreditLimit, updated.CreditDays));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ADifferentValueForASwitchedOffField_IsRefused()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var svc = Svc(db);
|
||||
var created = (await svc.CreateAsync(NewCustomer(r => r.CreditLimit = 5000m))).Value;
|
||||
await SwitchesAsync(db, c => c.CustomerCreditLimitEnabled = false);
|
||||
var stored = db.Customers.Single(c => c.CustomerId == created.CustomerId);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => svc.UpdateAsync(created.CustomerId, Same(stored, r => r.CreditLimit = 9999m), stored.RowVersion));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ public class DualApprovalTests
|
||||
[Fact]
|
||||
public async Task SecondApproval_CanBeLimitedToRoles()
|
||||
{
|
||||
var h = await Harness.CreateAsync(level2Roles: "Approver");
|
||||
var h = await Harness.CreateAsync(level2RoleIds: ApproverRoleId.ToString());
|
||||
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
|
||||
h.User.Be(ApproverA);
|
||||
await h.Stock.ApproveAsync(id, null);
|
||||
@@ -250,7 +250,7 @@ public class DualApprovalTests
|
||||
public required AdjustmentService Stock { get; init; }
|
||||
public required PriceAdjustmentService Prices { get; init; }
|
||||
|
||||
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2Roles = null)
|
||||
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null)
|
||||
{
|
||||
var db = new ErpDbContext(
|
||||
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
|
||||
@@ -283,7 +283,7 @@ public class DualApprovalTests
|
||||
db.ApprovalConfig.Add(new ApprovalConfig
|
||||
{
|
||||
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
|
||||
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleCodes = level2Roles
|
||||
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleIds = level2RoleIds
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -292,7 +292,7 @@ public class DualApprovalTests
|
||||
var notifications = new RecordingNotifications();
|
||||
var approvals = new ApprovalService(
|
||||
new Repository<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db),
|
||||
new Repository<RolePermission>(db), user, notifications, uow);
|
||||
new Repository<RolePermission>(db), new Repository<Role>(db), user, notifications, uow);
|
||||
var gl = new FakeGeneralLedger();
|
||||
var mutator = new RecordingMutator();
|
||||
var numbers = new FakeNumberSequence();
|
||||
@@ -308,8 +308,9 @@ public class DualApprovalTests
|
||||
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
|
||||
new Repository<ProductConfig>(db), user));
|
||||
var prices = new PriceAdjustmentService(
|
||||
new Repository<PriceAdjustment>(db), new Repository<Item>(db), new Repository<ItemPriceHistory>(db),
|
||||
new Repository<User>(db), new Repository<ApprovalConfig>(db), approvals, notifications, numbers, user, uow);
|
||||
new Repository<PriceAdjustment>(db), new Repository<PriceAdjustmentLine>(db), new Repository<Item>(db),
|
||||
new Repository<ItemPriceHistory>(db), new Repository<User>(db), new Repository<ApprovalConfig>(db),
|
||||
approvals, notifications, numbers, user, uow);
|
||||
|
||||
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
|
||||
}
|
||||
@@ -323,7 +324,13 @@ public class DualApprovalTests
|
||||
[Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<int, int> RoleIds = new()
|
||||
{
|
||||
[Requester] = 1, [ApproverA] = ApproverRoleId, [ApproverB] = ApproverRoleId, [Clerk] = ClerkRoleId
|
||||
};
|
||||
|
||||
public int AuditUserId { get; private set; } = userId;
|
||||
public int? RoleId { get; private set; } = RoleIds[userId];
|
||||
public string? RoleCode { get; private set; } = roleCode;
|
||||
public string UserId => AuditUserId.ToString();
|
||||
public bool IsAuthenticated => true;
|
||||
@@ -333,6 +340,7 @@ public class DualApprovalTests
|
||||
public void Be(int userId)
|
||||
{
|
||||
AuditUserId = userId;
|
||||
RoleId = RoleIds[userId];
|
||||
RoleCode = Roles[userId];
|
||||
}
|
||||
}
|
||||
@@ -368,7 +376,7 @@ public class DualApprovalTests
|
||||
=> throw new NotSupportedException();
|
||||
public Task<StockLedger> PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId,
|
||||
DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null) => throw new NotSupportedException();
|
||||
DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null, bool postGlJournal = true) => throw new NotSupportedException();
|
||||
public Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using ERPCore.Controllers;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Search;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Permission coverage (Phase B): every API endpoint that changes data sits behind a permission, every permission the
|
||||
/// controllers name actually exists in the seeded catalogue, HR data is gated, global search respects the role,
|
||||
/// state-changing cookie requests need the CSRF token, and a branch-less person can't read every branch.
|
||||
/// </summary>
|
||||
public class PermissionCoverageTests
|
||||
{
|
||||
// The seeded (HasData) rows live in the design-time model; the runtime model drops them.
|
||||
private static IEnumerable<IDictionary<string, object?>> Seed<T>(ErpDbContext db) =>
|
||||
Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions.GetService<IDesignTimeModel>(db)
|
||||
.Model.FindEntityType(typeof(T))!.GetSeedData();
|
||||
|
||||
// ---------------------------------------------------------------- controllers <-> permissions
|
||||
|
||||
private static IEnumerable<Type> ApiControllers() =>
|
||||
typeof(ApiControllerBase).Assembly.GetTypes()
|
||||
.Where(t => !t.IsAbstract && typeof(ApiControllerBase).IsAssignableFrom(t));
|
||||
|
||||
private static IEnumerable<string> NavPolicyNames(MemberInfo member) =>
|
||||
member.GetCustomAttributes<AuthorizeAttribute>(inherit: true)
|
||||
.Select(a => a.Policy)
|
||||
.Where(p => p is not null && p.StartsWith(NavPolicies.Prefix, StringComparison.Ordinal))!;
|
||||
|
||||
private static bool IsReadOnly(MethodInfo m) =>
|
||||
m.GetCustomAttributes<HttpGetAttribute>().Any() && !m.GetCustomAttributes().OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>().Any(a => a is not HttpGetAttribute);
|
||||
|
||||
/// <summary>Endpoints that deliberately sit behind the login door only: personal, or checked inside the service.</summary>
|
||||
private static readonly HashSet<string> OpenByDesign = new()
|
||||
{
|
||||
nameof(AuthController), // sign-in, sessions, password: the person's own
|
||||
nameof(NavController), // "what may I see": the person's own permissions
|
||||
nameof(NotificationsController), // the person's own inbox
|
||||
nameof(SupportTicketsController), // anyone may raise a ticket; the service scopes by owner/admin
|
||||
nameof(TrackingController), // read-only serial/batch pickers used inside sales and stock screens
|
||||
nameof(SearchController), // gated per result type inside GlobalSearchService
|
||||
nameof(MetaController),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void EveryDataChangingEndpoint_IsBehindAPermission()
|
||||
{
|
||||
var uncovered = new List<string>();
|
||||
foreach (var controller in ApiControllers().Where(c => !OpenByDesign.Contains(c.Name)))
|
||||
{
|
||||
var classCovered = NavPolicyNames(controller).Any();
|
||||
foreach (var action in controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(m => m.GetCustomAttributes().OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>().Any()))
|
||||
{
|
||||
if (IsReadOnly(action)) continue;
|
||||
if (action.GetCustomAttributes<AllowAnonymousAttribute>().Any()) continue;
|
||||
if (!classCovered && !NavPolicyNames(action).Any()) uncovered.Add($"{controller.Name}.{action.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(uncovered.Count == 0, "Data-changing endpoints with no Nav permission: " + string.Join(", ", uncovered));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryControllerAboutSensitiveData_IsGatedAsAWhole()
|
||||
{
|
||||
// HR (salaries, bank accounts, loans, payslips) and the audit trail used to be open to every signed-in person.
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"AttendanceBatchesController", "LeaveRequestsController", "PayrollRunsController", "PayslipsController",
|
||||
"HrReportsController", "EmployeesController", "SalaryComponentsController", "TaxSlabsController",
|
||||
"PayrollStatutorySettingsController", "AuditLogsController",
|
||||
})
|
||||
{
|
||||
var type = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == name);
|
||||
Assert.True(NavPolicyNames(type).Any(), $"{name} has no class-level Nav permission.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PayData_NeedsThePayrollPermission_OnTopOfEmployees()
|
||||
{
|
||||
var employees = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == "EmployeesController");
|
||||
foreach (var action in new[] { "ListBankDetails", "GetSalaryStructureHistory", "ListLoans", "GetLoan", "CreateLoan", "CreateSalaryStructure", "ReplaceBankDetails" })
|
||||
{
|
||||
var method = employees.GetMethod(action)!;
|
||||
Assert.Contains(NavPolicyNames(method), p => p.StartsWith(NavPolicies.Prefix + "hrm.payroll", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var reports = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == "HrReportsController");
|
||||
foreach (var action in new[] { "PayrollRegister", "SalaryHistory" })
|
||||
Assert.Contains(NavPolicyNames(reports.GetMethod(action)!), p => p == NavPolicies.Prefix + "hrm.payroll");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryNavCodeTheControllersName_ExistsInTheSeededCatalogue()
|
||||
{
|
||||
// The old Nav:vendors policy named a code that no longer existed, so only administrators got through.
|
||||
using var db = CreateContext();
|
||||
var seeded = Seed<Permission>(db)
|
||||
.Select(d => ((string)d[nameof(Permission.Code)]!).Substring("NAV:".Length))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = new SortedSet<string>();
|
||||
foreach (var controller in ApiControllers())
|
||||
{
|
||||
var names = NavPolicyNames(controller).Concat(
|
||||
controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).SelectMany(NavPolicyNames));
|
||||
foreach (var name in names)
|
||||
{
|
||||
var rest = name[NavPolicies.Prefix.Length..];
|
||||
var sep = rest.LastIndexOf(NavPolicies.ActionSeparator, StringComparison.Ordinal);
|
||||
var code = sep >= 0 && Enum.TryParse<RolePermissionAction>(rest[(sep + 1)..], out _) ? rest[..sep] : rest;
|
||||
// A module code is satisfied by the module itself or any screen inside it (HasAccessAsync does the same).
|
||||
if (!seeded.Contains(code) && !seeded.Any(s => s.StartsWith(code + ".", StringComparison.Ordinal))) missing.Add($"{controller.Name}: {code}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(missing.Count == 0, "Controllers name permissions that are not seeded: " + string.Join("; ", missing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeededCatalogue_HasNoDuplicateCodes_AndEverySubItemHasAPermission()
|
||||
{
|
||||
using var db = CreateContext();
|
||||
var permissions = Seed<Permission>(db).ToList();
|
||||
var codes = permissions.Select(p => (string)p[nameof(Permission.Code)]!).ToList();
|
||||
Assert.Equal(codes.Count, codes.Distinct().Count());
|
||||
|
||||
var subItems = Seed<SubNavItem>(db);
|
||||
var granted = permissions.Where(p => p[nameof(Permission.SubNavItemId)] is not null).Select(p => (int)p[nameof(Permission.SubNavItemId)]!).ToHashSet();
|
||||
foreach (var sub in subItems)
|
||||
Assert.True(granted.Contains((int)sub[nameof(SubNavItem.SubNavItemId)]!), $"No permission row for sub item {sub[nameof(SubNavItem.Code)]}.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hrm")]
|
||||
[InlineData("hrm.employees")]
|
||||
[InlineData("hrm.payroll")]
|
||||
[InlineData("sales-returns")]
|
||||
[InlineData("settings.audit-logs")]
|
||||
public void NewPermissions_AreSeeded(string code)
|
||||
{
|
||||
using var db = CreateContext();
|
||||
var codes = Seed<Permission>(db).Select(d => (string)d[nameof(Permission.Code)]!);
|
||||
Assert.Contains("NAV:" + code, codes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the policies actually decide
|
||||
|
||||
private sealed class FakeUser(int? roleId, bool isAdmin = false, int? branchId = null, bool branching = true) : ICurrentUser
|
||||
{
|
||||
public int AuditUserId => 10;
|
||||
public int? RoleId => roleId;
|
||||
public string? RoleCode => null;
|
||||
public bool IsAdmin => isAdmin;
|
||||
public string UserId => "10";
|
||||
public bool IsAuthenticated => true;
|
||||
public int? BranchId => branchId;
|
||||
public bool BranchingEnabled => branching;
|
||||
}
|
||||
|
||||
private static async Task<(ErpDbContext Db, NavPermissionService Nav)> RolesAsync()
|
||||
{
|
||||
var db = CreateContext();
|
||||
db.Roles.AddRange(
|
||||
new Role { RoleId = 1, Code = "Hr", Name = "HR officer", CreatedAt = DateTime.UtcNow },
|
||||
new Role { RoleId = 2, Code = "Clerk", Name = "Clerk", CreatedAt = DateTime.UtcNow });
|
||||
db.NavItems.Add(new NavItem { NavItemId = 100, Code = "hrm", Label = "HR" });
|
||||
db.SubNavItems.AddRange(
|
||||
new SubNavItem { SubNavItemId = 101, NavItemId = 100, Code = "hrm.employees", Label = "Employees" },
|
||||
new SubNavItem { SubNavItemId = 102, NavItemId = 100, Code = "hrm.payroll", Label = "Payroll" });
|
||||
db.Permissions.AddRange(
|
||||
new Permission { PermissionId = 200, Code = "NAV:hrm.employees", SubNavItemId = 101 },
|
||||
new Permission { PermissionId = 201, Code = "NAV:hrm.payroll", SubNavItemId = 102 });
|
||||
db.RolePermissions.Add(new RolePermission { RoleId = 1, PermissionId = 200, Actions = RolePermissionAction.View | RolePermissionAction.Edit });
|
||||
await db.SaveChangesAsync();
|
||||
return (db, new NavPermissionService(new Repository<Role>(db), new Repository<RolePermission>(db), new MemoryCache(new MemoryCacheOptions())));
|
||||
}
|
||||
|
||||
private static async Task<bool> PassesAsync(NavPermissionService nav, ICurrentUser user, string policyName)
|
||||
{
|
||||
var provider = new NavPolicyProvider(Microsoft.Extensions.Options.Options.Create(new AuthorizationOptions()));
|
||||
var policy = (await provider.GetPolicyAsync(policyName))!;
|
||||
var requirement = policy.Requirements.OfType<NavRequirement>().Single();
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, new ClaimsPrincipal(new ClaimsIdentity("test")), null);
|
||||
await new NavAuthorizationHandler(user, nav).HandleAsync(context);
|
||||
return context.HasSucceeded;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HrOfficer_ReachesEmployees_ButNotPayData_AndAnAdministratorReachesAll()
|
||||
{
|
||||
var (db, nav) = await RolesAsync();
|
||||
await using var _ = db;
|
||||
var hr = new FakeUser(roleId: 1);
|
||||
|
||||
Assert.True(await PassesAsync(nav, hr, "Nav:hrm")); // the module, via a screen inside it
|
||||
Assert.True(await PassesAsync(nav, hr, "Nav:hrm.employees"));
|
||||
Assert.True(await PassesAsync(nav, hr, "Nav:hrm.employees:Edit"));
|
||||
Assert.False(await PassesAsync(nav, hr, "Nav:hrm.employees:Delete")); // not granted
|
||||
Assert.False(await PassesAsync(nav, hr, "Nav:hrm.payroll")); // salaries, loans, bank accounts
|
||||
Assert.False(await PassesAsync(nav, hr, "Nav:settings.audit-logs"));
|
||||
|
||||
Assert.False(await PassesAsync(nav, new FakeUser(roleId: 2), "Nav:hrm"));
|
||||
Assert.False(await PassesAsync(nav, new FakeUser(roleId: null), "Nav:hrm"));
|
||||
|
||||
Assert.True(await PassesAsync(nav, new FakeUser(roleId: 2, isAdmin: true), "Nav:hrm.payroll:Approve"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- global search follows the role
|
||||
|
||||
[Fact]
|
||||
public async Task Search_OnlyReturnsKindsTheRoleMayOpen()
|
||||
{
|
||||
var (db, nav) = await RolesAsync();
|
||||
await using var _ = db;
|
||||
db.Employees.Add(new Employee { EmployeeId = 1, EmployeeCode = "E-1", FullName = "Alice Perera", BranchId = Branch1 });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
GlobalSearchService Search(ICurrentUser u) => new(
|
||||
new Repository<Item>(db), new Repository<PurchaseOrder>(db), new Repository<SalesSlip>(db), new Repository<Employee>(db), u, nav);
|
||||
|
||||
var hr = await Search(new FakeUser(roleId: 1)).SearchAsync("Alice");
|
||||
Assert.Contains(hr, r => r.Type == GlobalSearchResultType.Employee);
|
||||
|
||||
var clerk = await Search(new FakeUser(roleId: 2)).SearchAsync("Alice");
|
||||
Assert.DoesNotContain(clerk, r => r.Type == GlobalSearchResultType.Employee);
|
||||
|
||||
var admin = await Search(new FakeUser(roleId: 2, isAdmin: true)).SearchAsync("Alice");
|
||||
Assert.Contains(admin, r => r.Type == GlobalSearchResultType.Employee);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CSRF on every cookie-authenticated mutation
|
||||
|
||||
private static (CsrfProtectionFilter Filter, ActionExecutingContext Context) CsrfCall(
|
||||
string method, string? cookie = "token", string? header = null, bool sessionCookie = true,
|
||||
bool bearer = false, bool anonymousEndpoint = false, bool hasCsrfAttribute = false)
|
||||
{
|
||||
var http = new DefaultHttpContext();
|
||||
http.Request.Method = method;
|
||||
var cookies = new List<string>();
|
||||
if (sessionCookie) cookies.Add("erp_at=jwt");
|
||||
if (cookie is not null) cookies.Add($"XSRF-TOKEN={cookie}");
|
||||
if (cookies.Count > 0) http.Request.Headers.Cookie = string.Join("; ", cookies);
|
||||
if (header is not null) http.Request.Headers["X-XSRF-TOKEN"] = header;
|
||||
if (bearer) http.Request.Headers.Authorization = "Bearer abc";
|
||||
|
||||
var metadata = new List<object>();
|
||||
if (anonymousEndpoint) metadata.Add(new AllowAnonymousAttribute());
|
||||
if (hasCsrfAttribute) metadata.Add(new ValidateCsrfAttribute());
|
||||
http.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(metadata), "test"));
|
||||
|
||||
var actionContext = new ActionContext(http, new RouteData(), new ActionDescriptor());
|
||||
var context = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object?>(), new object());
|
||||
return (new CsrfProtectionFilter(), context);
|
||||
}
|
||||
|
||||
private static async Task<bool> RunAsync(CsrfProtectionFilter filter, ActionExecutingContext context)
|
||||
{
|
||||
var ran = false;
|
||||
await filter.OnActionExecutionAsync(context, () =>
|
||||
{
|
||||
ran = true;
|
||||
return Task.FromResult(new ActionExecutedContext(context, context.Filters, context.Controller));
|
||||
});
|
||||
return ran;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("POST")]
|
||||
[InlineData("PUT")]
|
||||
[InlineData("PATCH")]
|
||||
[InlineData("DELETE")]
|
||||
public async Task CookieRequest_WithoutTheCsrfHeader_IsRefused(string method)
|
||||
{
|
||||
var (filter, context) = CsrfCall(method, cookie: "token", header: null);
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => RunAsync(filter, context));
|
||||
Assert.Equal(ErrorCodes.CsrfTokenMismatch, ex.Code);
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CookieRequest_WithAWrongOrMissingCsrfCookie_IsRefused()
|
||||
{
|
||||
var (f1, c1) = CsrfCall("POST", cookie: "token", header: "other");
|
||||
await Assert.ThrowsAsync<DomainException>(() => RunAsync(f1, c1));
|
||||
|
||||
var (f2, c2) = CsrfCall("POST", cookie: null, header: "token");
|
||||
await Assert.ThrowsAsync<DomainException>(() => RunAsync(f2, c2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CookieRequest_WithMatchingCsrfHeader_Runs()
|
||||
{
|
||||
var (filter, context) = CsrfCall("DELETE", cookie: "token", header: "token");
|
||||
Assert.True(await RunAsync(filter, context));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET")]
|
||||
[InlineData("HEAD")]
|
||||
[InlineData("OPTIONS")]
|
||||
public async Task SafeMethods_AreNotChecked(string method)
|
||||
{
|
||||
var (filter, context) = CsrfCall(method, cookie: null);
|
||||
Assert.True(await RunAsync(filter, context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BearerCallers_AnonymousEndpoints_AndRequestsWithoutASessionCookie_AreNotChecked()
|
||||
{
|
||||
var (f1, c1) = CsrfCall("POST", cookie: null, bearer: true);
|
||||
Assert.True(await RunAsync(f1, c1));
|
||||
|
||||
var (f2, c2) = CsrfCall("POST", cookie: null, anonymousEndpoint: true);
|
||||
Assert.True(await RunAsync(f2, c2));
|
||||
|
||||
var (f3, c3) = CsrfCall("POST", cookie: null, sessionCookie: false);
|
||||
Assert.True(await RunAsync(f3, c3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActionsThatCarryTheirOwnCsrfAttribute_AreNotCheckedTwice()
|
||||
{
|
||||
var (filter, context) = CsrfCall("POST", cookie: null, hasCsrfAttribute: true);
|
||||
Assert.True(await RunAsync(filter, context)); // the attribute itself enforces it
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheCsrfFilter_IsRegisteredGlobally()
|
||||
{
|
||||
var program = File.ReadAllText(Path.Combine(FindBackendRoot(), "ERPCore", "Program.cs"));
|
||||
Assert.Contains("Filters.Add<ERPCore.Infra.Auth.CsrfProtectionFilter>()", program);
|
||||
}
|
||||
|
||||
private static string FindBackendRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "ERPCore"))) dir = dir.Parent;
|
||||
return dir?.FullName ?? throw new DirectoryNotFoundException("Backend root not found.");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- a branch-less person can't read every branch
|
||||
|
||||
[Fact]
|
||||
public void BranchContext_AdministratorsAreNeverScoped()
|
||||
{
|
||||
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, isAdmin: true, branchId: 5));
|
||||
Assert.False(ctx.IsActive);
|
||||
Assert.Null(ctx.BranchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BranchContext_AnEmployeeIsScopedToTheirOwnBranch()
|
||||
{
|
||||
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: 5));
|
||||
Assert.True(ctx.IsActive);
|
||||
Assert.Equal(5, ctx.BranchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BranchContext_APersonWithNoBranch_InABranchingDeployment_SeesNoBranchAtAll()
|
||||
{
|
||||
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: null, branching: true));
|
||||
Assert.True(ctx.IsActive);
|
||||
Assert.Equal(CurrentBranchContext.NoBranchId, ctx.BranchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BranchContext_APersonWithNoBranch_WhenBranchingIsOff_IsNotScoped()
|
||||
{
|
||||
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: null, branching: false));
|
||||
Assert.False(ctx.IsActive);
|
||||
Assert.Null(ctx.BranchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryFilters_HideEveryWarehouse_FromABranchlessNonAdmin_ButNotFromAnAdminOrWhenBranchingIsOff()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ErpDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString("N")).Options;
|
||||
|
||||
async Task<int> CountFor(ICurrentUser user, bool seed)
|
||||
{
|
||||
await using var db = new ErpDbContext(options, user, new CurrentBranchContext(user));
|
||||
if (seed)
|
||||
{
|
||||
db.Warehouses.AddRange(
|
||||
new Warehouse { WarehouseId = 1, Code = "W1", Name = "One", BranchId = 1 },
|
||||
new Warehouse { WarehouseId = 2, Code = "W2", Name = "Two", BranchId = 2 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return db.Warehouses.Count();
|
||||
}
|
||||
|
||||
Assert.Equal(2, await CountFor(new FakeUser(1, isAdmin: true), seed: true));
|
||||
Assert.Equal(1, await CountFor(new FakeUser(1, branchId: 2), seed: false));
|
||||
Assert.Equal(0, await CountFor(new FakeUser(1, branchId: null, branching: true), seed: false));
|
||||
Assert.Equal(2, await CountFor(new FakeUser(1, branchId: null, branching: false), seed: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
using System.Text.Json;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Notifications;
|
||||
using ERPCore.Dtos.OpeningBalances;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Dtos.Vendors;
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Procurement configuration (vendor contact / address, PO tax) and the one-time opening balances.
|
||||
/// Every rule here is also enforced by the UI, so these tests are about the server refusing what a stale
|
||||
/// or hand-built request sends.
|
||||
/// </summary>
|
||||
public class ProcurementAndOpeningBalanceTests
|
||||
{
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
private static async Task SeedProcurementConfigAsync(
|
||||
ErpDbContext db, VendorContactMode mode = VendorContactMode.None, bool address = false, bool tax = false,
|
||||
bool terms = true, bool taxReg = true)
|
||||
{
|
||||
db.ProcurementConfig.Add(new ProcurementConfig
|
||||
{
|
||||
ConfigId = ProcurementConfig.SingletonId,
|
||||
VendorContactMode = mode,
|
||||
VendorAddressEnabled = address,
|
||||
PoTaxEnabled = tax,
|
||||
VendorPaymentTermsEnabled = terms,
|
||||
VendorTaxRegEnabled = taxReg
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static ProcurementConfigService ConfigService(ErpDbContext db) =>
|
||||
new(new Repository<ProcurementConfig>(db), new TestCurrentUser(), new PassThroughUnitOfWork(db));
|
||||
|
||||
private static VendorService VendorSvc(ErpDbContext db) =>
|
||||
new(new Repository<Vendor>(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db));
|
||||
|
||||
private static CreateVendorRequest NewVendor(string code, Action<CreateVendorRequest>? tweak = null)
|
||||
{
|
||||
var request = new CreateVendorRequest { Code = code, Name = $"Vendor {code}", Currency = "LKR", BranchId = Branch1 };
|
||||
tweak?.Invoke(request);
|
||||
return request;
|
||||
}
|
||||
|
||||
private sealed class NullNotifications : INotificationService
|
||||
{
|
||||
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- vendors
|
||||
|
||||
[Fact]
|
||||
public async Task Vendor_Mobile_IsRejected_WhileContactModeIsNone()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db, VendorContactMode.None);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => VendorSvc(db).CreateAsync(NewVendor("V1", r => r.Mobile = "0771234567")));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
|
||||
Assert.Empty(db.Vendors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Vendor_Mobile_IsSaved_WhenContactModeIsMobile_AndEmailIsStillRefused()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db, VendorContactMode.Mobile);
|
||||
|
||||
var created = (await VendorSvc(db).CreateAsync(NewVendor("V2", r => r.Mobile = " +94 77 123 4567 "))).Value;
|
||||
Assert.Equal("+94 77 123 4567", created.Mobile);
|
||||
Assert.Null(created.Email);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => VendorSvc(db).CreateAsync(NewVendor("V3", r => r.Email = "a@b.lk")));
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abc")]
|
||||
[InlineData("12")]
|
||||
public async Task Vendor_InvalidMobile_IsRejected(string mobile)
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db, VendorContactMode.Mobile);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => VendorSvc(db).CreateAsync(NewVendor("V4", r => r.Mobile = mobile)));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Vendor_Address_NeedsTheSwitchOn()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db, address: false);
|
||||
|
||||
var off = await Assert.ThrowsAsync<DomainException>(
|
||||
() => VendorSvc(db).CreateAsync(NewVendor("V5", r => r.Address = "12 Galle Road")));
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, off.Code);
|
||||
|
||||
db.ProcurementConfig.Single().VendorAddressEnabled = true;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var created = (await VendorSvc(db).CreateAsync(NewVendor("V6", r => r.Address = "12 Galle Road"))).Value;
|
||||
Assert.Equal("12 Galle Road", created.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Vendor_Update_KeepsStoredContact_WhenTheSwitchIsOff()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: true);
|
||||
var created = (await VendorSvc(db).CreateAsync(NewVendor("V7", r =>
|
||||
{
|
||||
r.Email = "accounts@supplier.lk";
|
||||
r.Address = "Colombo";
|
||||
}))).Value;
|
||||
|
||||
// Contact mode switched off later: an update must not erase what was recorded.
|
||||
db.ProcurementConfig.Single().VendorContactMode = VendorContactMode.None;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId);
|
||||
var updated = (await VendorSvc(db).UpdateAsync(
|
||||
created.VendorId,
|
||||
new UpdateVendorRequest { Code = "V7", Name = "Renamed", Currency = "LKR", BranchId = Branch1 },
|
||||
tracked.RowVersion)).Value;
|
||||
|
||||
Assert.Equal("Renamed", updated.Name);
|
||||
Assert.Equal("accounts@supplier.lk", db.Vendors.Single().Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Vendor_TermsAndTaxReg_AreOnByDefault_AndSaved()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db); // defaults: terms + tax registration on
|
||||
|
||||
var created = (await VendorSvc(db).CreateAsync(NewVendor("V8", r =>
|
||||
{
|
||||
r.Terms = " NET30 ";
|
||||
r.TaxReg = "134567890-7000";
|
||||
}))).Value;
|
||||
|
||||
Assert.Equal("NET30", created.Terms);
|
||||
Assert.Equal("134567890-7000", created.TaxReg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Vendor_TermsAndTaxReg_AreRejectedWhenTheirSwitchesAreOff()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db, terms: false, taxReg: false);
|
||||
|
||||
var terms = await Assert.ThrowsAsync<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V9", r => r.Terms = "NET30")));
|
||||
var taxReg = await Assert.ThrowsAsync<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V10", r => r.TaxReg = "123")));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, terms.Code);
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, taxReg.Code);
|
||||
Assert.Empty(db.Vendors);
|
||||
|
||||
// Blank is fine: the form simply doesn't send them.
|
||||
var ok = (await VendorSvc(db).CreateAsync(NewVendor("V11"))).Value;
|
||||
Assert.Null(ok.Terms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Vendor_Update_KeepsStoredTermsAndTaxReg_WhenSwitchedOff()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db);
|
||||
var created = (await VendorSvc(db).CreateAsync(NewVendor("V12", r =>
|
||||
{
|
||||
r.Terms = "NET45";
|
||||
r.TaxReg = "TRN-1";
|
||||
}))).Value;
|
||||
|
||||
var cfg = db.ProcurementConfig.Single();
|
||||
cfg.VendorPaymentTermsEnabled = false;
|
||||
cfg.VendorTaxRegEnabled = false;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId);
|
||||
var updated = (await VendorSvc(db).UpdateAsync(
|
||||
created.VendorId,
|
||||
new UpdateVendorRequest { Code = "V12", Name = "Renamed", Currency = "LKR", BranchId = Branch1 },
|
||||
tracked.RowVersion)).Value;
|
||||
|
||||
Assert.Equal("Renamed", updated.Name);
|
||||
Assert.Equal(("NET45", "TRN-1"), (db.Vendors.Single().Terms, db.Vendors.Single().TaxReg));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- purchase-order tax
|
||||
|
||||
private static PurchaseOrderService PoSvc(ErpDbContext db) =>
|
||||
new(new Repository<PurchaseOrder>(db), new Repository<Vendor>(db), new Repository<Requisition>(db),
|
||||
new Repository<Item>(db), new Repository<Uom>(db), new Repository<Warehouse>(db),
|
||||
new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(),
|
||||
ConfigService(db), new PassThroughUnitOfWork(db));
|
||||
|
||||
private static async Task<int> SeedVendorAndItemAsync(ErpDbContext db)
|
||||
{
|
||||
db.Vendors.Add(new Vendor { VendorId = 1, Code = "V-PO", Name = "PO Vendor", BranchId = Branch1, Status = EntityStatus.Active });
|
||||
db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola 500ml", CategoryId = 1, BaseUomId = 1 });
|
||||
await db.SaveChangesAsync();
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static CreatePurchaseOrderRequest PoRequest(decimal taxRate) => new()
|
||||
{
|
||||
VendorId = 1,
|
||||
BranchId = Branch1,
|
||||
SaveAsDraft = true,
|
||||
Lines = [new CreatePoLineInput { ItemId = 1, WarehouseId = Warehouse1, Qty = 10, UnitPrice = 100m, Tax = taxRate }]
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Po_TaxOn_StoresTheLineTaxAmount_AndTotalsIncludeIt()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db, tax: true);
|
||||
await SeedVendorAndItemAsync(db);
|
||||
|
||||
var po = (await PoSvc(db).CreateAsync(PoRequest(0.18m))).Value;
|
||||
|
||||
var line = Assert.Single(po.Lines);
|
||||
Assert.Equal(180m, line.TaxAmount);
|
||||
Assert.Equal((1000m, 180m, 1180m), (po.Totals.SubTotal, po.Totals.Tax, po.Totals.GrandTotal));
|
||||
Assert.Equal(180m, db.PoLines.Single().TaxAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Po_TaxAmount_RoundsHalfUp_ToTwoDecimals()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db, tax: true);
|
||||
await SeedVendorAndItemAsync(db);
|
||||
|
||||
// 3 x 33.5 = 100.5; at 5% the tax is 5.025 -> 5.03 (half away from zero, not banker's rounding).
|
||||
var request = PoRequest(0.05m);
|
||||
request.Lines[0].Qty = 3;
|
||||
request.Lines[0].UnitPrice = 33.5m;
|
||||
|
||||
var po = (await PoSvc(db).CreateAsync(request)).Value;
|
||||
|
||||
Assert.Equal(5.03m, Assert.Single(po.Lines).TaxAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Po_TaxOff_RejectsANonZeroRate_ButAcceptsZero()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db, tax: false);
|
||||
await SeedVendorAndItemAsync(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => PoSvc(db).CreateAsync(PoRequest(0.18m)));
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
|
||||
Assert.Empty(db.PurchaseOrders);
|
||||
|
||||
var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value;
|
||||
Assert.Equal(0m, po.Totals.Tax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Po_CarriesVendorDetails_OnlyForTheEnabledFields()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: false);
|
||||
await SeedVendorAndItemAsync(db);
|
||||
var vendor = db.Vendors.Single();
|
||||
vendor.Mobile = "0771234567";
|
||||
vendor.Email = "po@vendor.lk";
|
||||
vendor.Address = "Colombo";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value;
|
||||
|
||||
Assert.NotNull(po.Vendor);
|
||||
Assert.Equal("PO Vendor", po.Vendor!.Name);
|
||||
Assert.Equal("po@vendor.lk", po.Vendor.Email);
|
||||
Assert.Null(po.Vendor.Mobile); // contact mode is Email
|
||||
Assert.Null(po.Vendor.Address); // address switched off
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- purchase-order list: date filter + sort
|
||||
|
||||
/// <summary>Three POs from two vendors, created on known business days; returns their ids in creation order.</summary>
|
||||
private static async Task<int[]> SeedListAsync(ErpDbContext db)
|
||||
{
|
||||
db.Vendors.AddRange(
|
||||
new Vendor { VendorId = 1, Code = "V-B", Name = "Beta Traders", BranchId = Branch1, Status = EntityStatus.Active },
|
||||
new Vendor { VendorId = 2, Code = "V-A", Name = "Alpha Foods", BranchId = Branch1, Status = EntityStatus.Active });
|
||||
db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola", CategoryId = 1, BaseUomId = 1 });
|
||||
|
||||
PurchaseOrder Po(int id, int vendor, DateTime createdUtc, decimal qty) => new()
|
||||
{
|
||||
PoId = id, DocNo = $"PO-{id}", VendorId = vendor, BranchId = Branch1, Status = PurchaseOrderStatus.Draft,
|
||||
CreatedAt = createdUtc,
|
||||
Lines = [new PoLine { ItemId = 1, WarehouseId = Warehouse1, Qty = qty, UnitPrice = 10m }]
|
||||
};
|
||||
db.PurchaseOrders.AddRange(
|
||||
Po(1, 1, new DateTime(2026, 4, 1, 4, 0, 0, DateTimeKind.Utc), 5), // 1 Apr (Colombo 09:30), total 50
|
||||
Po(2, 2, new DateTime(2026, 4, 1, 20, 0, 0, DateTimeKind.Utc), 30), // 2 Apr in Colombo (01:30), total 300
|
||||
Po(3, 1, new DateTime(2026, 4, 10, 6, 0, 0, DateTimeKind.Utc), 10)); // 10 Apr, total 100
|
||||
await db.SaveChangesAsync();
|
||||
return [1, 2, 3];
|
||||
}
|
||||
|
||||
private static async Task<List<int>> ListIds(PurchaseOrderService svc, string? sort = null, DateOnly? from = null, DateOnly? to = null)
|
||||
{
|
||||
var page = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Sort = sort, PageSize = 50 }, null, null, from, to);
|
||||
return page.Items.Select(i => i.PoId).ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PoList_DateRange_UsesTheBusinessDayOfTheCreatedTime_AndIsInclusive()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db);
|
||||
await SeedListAsync(db);
|
||||
var svc = PoSvc(db);
|
||||
|
||||
// PO 2 was created 20:00 UTC on 1 Apr = 2 Apr in Colombo, so a "1 Apr only" filter must not include it.
|
||||
Assert.Equal([1], await ListIds(svc, from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 1)));
|
||||
Assert.Equal([2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 2)));
|
||||
Assert.Equal([3, 2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 10)));
|
||||
Assert.Equal([3, 2, 1], await ListIds(svc, to: new DateOnly(2026, 4, 30)));
|
||||
Assert.Equal([3], await ListIds(svc, from: new DateOnly(2026, 4, 3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PoList_Sorts_ByCreatedVendorAndTotal_BothWays()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db);
|
||||
await SeedListAsync(db);
|
||||
var svc = PoSvc(db);
|
||||
|
||||
Assert.Equal([3, 2, 1], await ListIds(svc)); // default: newest first
|
||||
Assert.Equal([1, 2, 3], await ListIds(svc, "created")); // oldest first
|
||||
Assert.Equal([2, 3, 1], await ListIds(svc, "vendor")); // Alpha, then Beta (ties: newest first)
|
||||
Assert.Equal([3, 1, 2], await ListIds(svc, "-vendor"));
|
||||
Assert.Equal([2, 3, 1], await ListIds(svc, "-total")); // 300, 100, 50
|
||||
Assert.Equal([1, 3, 2], await ListIds(svc, "total"));
|
||||
Assert.Equal([3, 2, 1], await ListIds(svc, "nonsense")); // unknown key falls back to the default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PoList_Search_MatchesVendorNameAndCode()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db);
|
||||
await SeedListAsync(db);
|
||||
var svc = PoSvc(db);
|
||||
|
||||
var byName = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "alpha", PageSize = 50 }, null, null);
|
||||
var byCode = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "V-B", PageSize = 50 }, null, null);
|
||||
|
||||
Assert.Equal([2], byName.Items.Select(i => i.PoId));
|
||||
Assert.Equal([3, 1], byCode.Items.Select(i => i.PoId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- opening balances
|
||||
|
||||
/// <summary>A GL that knows two LKR cash/bank accounts, optionally the equity account, and can create it.</summary>
|
||||
private sealed class OpeningGl : IGeneralLedgerService
|
||||
{
|
||||
public List<GlJournalEntryRequest> Posted { get; } = new();
|
||||
public int AccountCreates { get; private set; }
|
||||
public bool EquityExists { get; init; } = true;
|
||||
public string BankCurrency { get; init; } = "LKR";
|
||||
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
{
|
||||
AccountCreates++;
|
||||
return Task.FromResult(new GeneralLedgerResponse
|
||||
{
|
||||
StatusCode = 201,
|
||||
Body = """{"statusCode":201,"success":true,"message":"Account created successfully","data":{"accountCode":"3000"}}"""
|
||||
});
|
||||
}
|
||||
|
||||
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||
{
|
||||
Posted.Add(request);
|
||||
return Task.FromResult(new GlJournalEntryResult($"JV-2026-{Posted.Count:000000}", true));
|
||||
}
|
||||
|
||||
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
|
||||
|
||||
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<GlBankAccount>>(
|
||||
[
|
||||
new("Cash", 1, "Main Cash Account", null, "Petty Cash", "CASH-000001", 17, "1400-01-01", "LKR"),
|
||||
new("Bank", 2, "BOC Current", "Bank of Ceylon", null, "0001234567", 18, "1000-01", BankCurrency)
|
||||
]);
|
||||
|
||||
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<GlAccount>>(EquityExists
|
||||
? [new("3000", "Opening Balance Equity", 3, true, true)]
|
||||
: [new("1000", "Bank", 1, false, true)]);
|
||||
}
|
||||
|
||||
private static OpeningBalanceService OpeningSvc(ErpDbContext db, OpeningGl gl) =>
|
||||
new(new Repository<OpeningBalanceRun>(db), gl, new TestCurrentUser(), new PassThroughUnitOfWork(db));
|
||||
|
||||
private static PostCashBankOpeningRequest Opening(DateOnly date, params (string Code, decimal Amount)[] lines) => new()
|
||||
{
|
||||
CutoverDate = date,
|
||||
Lines = lines.Select(l => new OpeningBalanceLineInput { GlAccountCode = l.Code, Amount = l.Amount }).ToList()
|
||||
};
|
||||
|
||||
private static readonly DateOnly OpeningDate = new(2026, 4, 1);
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_PostsOneBalancedJournal_DebitingEachAccount_AndCreditingEquity()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var gl = new OpeningGl();
|
||||
|
||||
var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 500m), ("1000-01", 1500m)));
|
||||
|
||||
var entry = Assert.Single(gl.Posted);
|
||||
Assert.Equal("OPENING_BALANCE", entry.JournalType);
|
||||
Assert.Equal("OPENING", entry.SourceModule);
|
||||
Assert.Equal(OpeningDate, entry.EntryDate);
|
||||
Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount));
|
||||
Assert.Contains(entry.Lines, l => l is { AccountCode: "1400-01-01", DebitAmount: 500m, CreditAmount: 0m });
|
||||
Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 1500m, CreditAmount: 0m });
|
||||
Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 2000m });
|
||||
|
||||
Assert.Equal(2000m, run.TotalAmount);
|
||||
Assert.Equal("JV-2026-000001", run.GlJournalNo);
|
||||
Assert.Equal(2, run.Lines.Count);
|
||||
Assert.Equal(0, gl.AccountCreates); // the equity account already existed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_CreatesTheEquityAccount_WhenTheChartHasNone()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var gl = new OpeningGl { EquityExists = false };
|
||||
|
||||
await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m)));
|
||||
|
||||
Assert.Equal(1, gl.AccountCreates);
|
||||
Assert.Contains(Assert.Single(gl.Posted).Lines, l => l.AccountCode == "3000" && l.CreditAmount == 100m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_SecondPosting_IsRefused_AndPostsNothingMore()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var gl = new OpeningGl();
|
||||
var svc = OpeningSvc(db, gl);
|
||||
await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m)));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 999m))));
|
||||
|
||||
Assert.Equal(ErrorCodes.OpeningBalanceAlreadyPosted, ex.Code);
|
||||
Assert.Equal(409, ex.StatusCode);
|
||||
Assert.Single(gl.Posted);
|
||||
Assert.Equal(100m, Assert.Single(db.OpeningBalanceRuns.Include(r => r.Lines)).Lines.Single().Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_Overdraft_IsCreditedToTheAccount_AndEquityTakesTheNet()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var gl = new OpeningGl();
|
||||
|
||||
var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 300m), ("1000-01", -100m)));
|
||||
|
||||
var entry = Assert.Single(gl.Posted);
|
||||
Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 0m, CreditAmount: 100m });
|
||||
Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 200m });
|
||||
Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount));
|
||||
Assert.Equal(200m, run.TotalAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_ShowsPostedAmounts_OnTheStateEndpoint()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var svc = OpeningSvc(db, new OpeningGl());
|
||||
|
||||
var before = await svc.GetCashBankAsync();
|
||||
Assert.Null(before.Posted);
|
||||
Assert.All(before.Accounts, a => Assert.Null(a.Amount));
|
||||
|
||||
await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 250m)));
|
||||
var after = await svc.GetCashBankAsync();
|
||||
|
||||
Assert.NotNull(after.Posted);
|
||||
Assert.Equal(250m, after.Accounts.Single(a => a.GlAccountCode == "1400-01-01").Amount);
|
||||
Assert.Null(after.Accounts.Single(a => a.GlAccountCode == "1000-01").Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_RejectsBadInput_WithoutPostingAnything()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var gl = new OpeningGl();
|
||||
var svc = OpeningSvc(db, gl);
|
||||
|
||||
// not a cash/bank account
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("9999", 10m))));
|
||||
// more than 2 decimals
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 10.005m))));
|
||||
// nothing but zeros
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 0m))));
|
||||
// the same account twice
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 5m), ("1400-01-01", 6m))));
|
||||
// in the future
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30), ("1400-01-01", 5m))));
|
||||
|
||||
Assert.Empty(gl.Posted);
|
||||
Assert.Empty(db.OpeningBalanceRuns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_RefusesANonBaseCurrencyAccount()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var gl = new OpeningGl { BankCurrency = "USD" };
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1000-01", 100m))));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
Assert.Empty(gl.Posted);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- opening stock
|
||||
|
||||
private static OpeningStockService StockSvc(ErpDbContext db, OpeningGl gl)
|
||||
{
|
||||
var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
|
||||
var fifo = new FifoCostingService(
|
||||
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
|
||||
return new OpeningStockService(
|
||||
new Repository<OpeningBalanceRun>(db), new Repository<Item>(db), new Repository<Warehouse>(db),
|
||||
new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), fifo, gl, glAccounts,
|
||||
new TestCurrentUser(), new PassThroughUnitOfWork(db));
|
||||
}
|
||||
|
||||
private static Item StockedItem(int id, Action<Item>? tweak = null)
|
||||
{
|
||||
var item = new Item
|
||||
{
|
||||
ItemId = id, Sku = $"SKU-{id}", Name = $"Item {id}", CategoryId = 1, BaseUomId = 1,
|
||||
StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None, Status = EntityStatus.Active
|
||||
};
|
||||
tweak?.Invoke(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static PostOpeningStockRequest StockRequest(params (int Item, decimal Qty, decimal Cost)[] lines) => new()
|
||||
{
|
||||
CutoverDate = OpeningDate,
|
||||
Lines = lines.Select(l => new OpeningStockLineInput { ItemId = l.Item, WarehouseId = Warehouse1, Qty = l.Qty, UnitCost = l.Cost }).ToList()
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task OpeningStock_CreatesLayerAndLedgerRow_AndPostsExactlyOneJournal()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.Items.AddRange(StockedItem(1), StockedItem(2));
|
||||
await db.SaveChangesAsync();
|
||||
var gl = new OpeningGl();
|
||||
|
||||
var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 24m, 12.5m), (2, 10m, 3m)));
|
||||
|
||||
Assert.Equal(330m, run.TotalValue);
|
||||
Assert.Equal(2, run.LineCount);
|
||||
|
||||
var layer = db.StockLayers.Single(l => l.ItemId == 1);
|
||||
Assert.Equal((24m, 24m, 12.5m), (layer.QtyReceived, layer.QtyRemaining, layer.UnitCost));
|
||||
var row = db.StockLedger.Single(l => l.ItemId == 1);
|
||||
Assert.Equal(("OPN", Direction.In, 24m, 300m, 24m), (row.SourceDocType, row.Direction, row.QtyBase, row.Value, row.RunningBalance));
|
||||
Assert.Equal(run.OpeningBalanceRunId, row.SourceDocId);
|
||||
|
||||
// One journal in total — the regular stock path would have added a STOCK journal (Dr Inventory / Cr Clearing) per row.
|
||||
var entry = Assert.Single(gl.Posted);
|
||||
Assert.Equal(("OPENING", "OPENING_BALANCE"), (entry.SourceModule, entry.JournalType));
|
||||
Assert.Collection(entry.Lines,
|
||||
l => Assert.Equal(("1100", 330m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
|
||||
l => Assert.Equal(("3000", 0m, 330m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
|
||||
Assert.Equal(2, db.JournalEntryStubs.Count(j => j.SourceDocType == "OPN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpeningStock_RefusesAnItemThatAlreadyHasMovements_InThatWarehouse()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.Items.AddRange(StockedItem(1), StockedItem(2));
|
||||
db.StockLedger.Add(new StockLedger { ItemId = 1, WarehouseId = Warehouse1, Direction = Direction.In, QtyBase = 5, UnitCost = 1, Value = 5, RunningBalance = 5, SourceDocType = "GRN", SourceDocId = 1 });
|
||||
await db.SaveChangesAsync();
|
||||
var gl = new OpeningGl();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => StockSvc(db, gl).PostAsync(StockRequest((1, 3m, 2m), (2, 3m, 2m))));
|
||||
|
||||
Assert.Equal(409, ex.StatusCode);
|
||||
Assert.Contains("SKU-1", ex.Message);
|
||||
Assert.Empty(gl.Posted);
|
||||
Assert.Empty(db.OpeningBalanceRuns);
|
||||
Assert.Empty(db.StockLayers); // nothing at all was written, not even for the clean item
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpeningStock_CanBeLoadedInSeveralRuns_ForDifferentItems()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.Items.AddRange(StockedItem(1), StockedItem(2));
|
||||
await db.SaveChangesAsync();
|
||||
var gl = new OpeningGl();
|
||||
var svc = StockSvc(db, gl);
|
||||
|
||||
await svc.PostAsync(StockRequest((1, 5m, 2m)));
|
||||
await svc.PostAsync(StockRequest((2, 7m, 4m)));
|
||||
|
||||
Assert.Equal(2, gl.Posted.Count);
|
||||
Assert.Equal(2, (await svc.ListAsync()).Count);
|
||||
// ...but the same item cannot be opened a second time.
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpeningStock_RefusesUnsuitableItemsAndBadNumbers()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.Items.AddRange(
|
||||
StockedItem(1, i => i.TrackingMode = TrackingMode.Serial),
|
||||
StockedItem(2, i => i.TrackingMode = TrackingMode.Batch),
|
||||
StockedItem(3, i => i.StockNature = StockNature.Service),
|
||||
StockedItem(4, i => i.Status = EntityStatus.Inactive),
|
||||
StockedItem(5));
|
||||
await db.SaveChangesAsync();
|
||||
var gl = new OpeningGl();
|
||||
var svc = StockSvc(db, gl);
|
||||
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m)))); // serial
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((2, 1m, 1m)))); // batch
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((3, 1m, 1m)))); // service
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((4, 1m, 1m)))); // inactive
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((99, 1m, 1m)))); // unknown
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 0m, 1m)))); // zero qty
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, -1m)))); // negative cost
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1.00001m, 1m)))); // > 4 decimals
|
||||
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, 1m), (5, 2m, 1m)))); // duplicate
|
||||
|
||||
Assert.Empty(gl.Posted);
|
||||
Assert.Empty(db.StockLayers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpeningStock_ZeroCostRun_PostsNoJournal()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.Items.Add(StockedItem(1));
|
||||
await db.SaveChangesAsync();
|
||||
var gl = new OpeningGl();
|
||||
|
||||
var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 10m, 0m)));
|
||||
|
||||
Assert.Equal(0m, run.TotalValue);
|
||||
Assert.Null(run.GlJournalNo);
|
||||
Assert.Empty(gl.Posted);
|
||||
Assert.Equal(10m, db.StockLayers.Single().QtyRemaining);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- purchase-order rejection
|
||||
|
||||
[Fact]
|
||||
public async Task Po_Reject_ShowsRejected_NotDraftOrPending_AndCanBeRevisedAndResubmitted()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db);
|
||||
await SeedVendorAndItemAsync(db);
|
||||
var svc = PoSvc(db);
|
||||
|
||||
var po = (await svc.CreateAsync(PoRequest(0m))).Value;
|
||||
await svc.SubmitAsync(po.PoId);
|
||||
// Somebody else raised it: the approver can't be its creator.
|
||||
db.PurchaseOrders.Single().CreatedBy = 999;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var rejected = await svc.RejectAsync(po.PoId, "Wrong price");
|
||||
Assert.Equal(PurchaseOrderStatus.Rejected, rejected.Status);
|
||||
|
||||
// A rejected order is reopened like a draft: it can be sent for approval again...
|
||||
var resubmitted = await svc.SubmitAsync(po.PoId);
|
||||
Assert.Equal(PurchaseOrderStatus.PendingApproval, resubmitted.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Po_Rejected_CanBeDeleted_ButAnApprovedOneCannot()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
await SeedProcurementConfigAsync(db);
|
||||
await SeedVendorAndItemAsync(db);
|
||||
var svc = PoSvc(db);
|
||||
|
||||
var po = (await svc.CreateAsync(PoRequest(0m))).Value;
|
||||
await svc.SubmitAsync(po.PoId);
|
||||
db.PurchaseOrders.Single().CreatedBy = 999;
|
||||
await db.SaveChangesAsync();
|
||||
await svc.RejectAsync(po.PoId, null);
|
||||
|
||||
await svc.DeleteAsync(po.PoId);
|
||||
Assert.Empty(db.PurchaseOrders);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- General Ledger payload
|
||||
|
||||
private sealed class CapturingGlClient(int status, string body) : IGeneralLedgerClient
|
||||
{
|
||||
public string? SentBody { get; private set; }
|
||||
|
||||
public async Task<GeneralLedgerResponse> SendAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? requestBody, CancellationToken ct)
|
||||
{
|
||||
if (requestBody is not null)
|
||||
{
|
||||
using var reader = new StreamReader(requestBody);
|
||||
SentBody = await reader.ReadToEndAsync(ct);
|
||||
}
|
||||
return new GeneralLedgerResponse { StatusCode = status, ContentType = "application/json", Body = body };
|
||||
}
|
||||
}
|
||||
|
||||
private static GlJournalEntryRequest GrnJournal() => new()
|
||||
{
|
||||
PeriodId = 1,
|
||||
EntryDate = new DateOnly(2026, 10, 18),
|
||||
SourceModule = "GRN",
|
||||
Reference = "GRN-1",
|
||||
Lines = [new GlJournalEntryLineRequest("1100", 10m, 0m, "x"), new GlJournalEntryLineRequest("2100", 0m, 10m)]
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task GlPost_NeverSendsNulls_BecauseGlTreatsANullJournalTypeAsMissing()
|
||||
{
|
||||
// GL's request DTO has a non-nullable JournalType; an explicit "journalType": null is a 400 from its model
|
||||
// validation. Only opening balances set one, so every other posting (GRN, sales, ...) must leave it out.
|
||||
var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}");
|
||||
|
||||
var result = await new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None);
|
||||
|
||||
Assert.Equal("J-1", result.JournalNo);
|
||||
Assert.DoesNotContain("journalType", client.SentBody!);
|
||||
Assert.DoesNotContain("null", client.SentBody!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlPost_KeepsAJournalTypeWhenOneIsSet()
|
||||
{
|
||||
var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}");
|
||||
var request = GrnJournal();
|
||||
request.JournalType = "OPENING_BALANCE";
|
||||
|
||||
await new GeneralLedgerService(client).PostJournalEntryAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Contains("\"journalType\":\"OPENING_BALANCE\"", client.SentBody!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlPost_AValidationFailureFromGl_IsReportedWithItsReason_NotAsAGenericRejection()
|
||||
{
|
||||
var problem = "{\"title\":\"One or more validation errors occurred.\",\"status\":400,\"errors\":{\"JournalType\":[\"The JournalType field is required.\"]}}";
|
||||
var client = new CapturingGlClient(400, problem);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None));
|
||||
|
||||
Assert.Equal(ErrorCodes.GlRequestFailed, ex.Code);
|
||||
Assert.Contains("JournalType", ex.Message);
|
||||
Assert.Contains("required", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlPost_GlsOwnMessage_StillWins()
|
||||
{
|
||||
var client = new CapturingGlClient(400, "{\"StatusCode\":400,\"Success\":false,\"Message\":\"Period is locked\",\"Data\":null}");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None));
|
||||
|
||||
Assert.Equal("Period is locked", ex.Message);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- stock movement vs. document journal
|
||||
|
||||
[Fact]
|
||||
public async Task StockLedgerPost_AddsTheGenericStockJournal_OnlyWhenTheDocumentHasNoJournalOfItsOwn()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var gl = new OpeningGl();
|
||||
var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
|
||||
var fifo = new FifoCostingService(
|
||||
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// A GRN / sales invoice / adjustment posts its own balanced journal: the ledger row must not add a second one,
|
||||
// or the movement is counted twice in the General Ledger.
|
||||
await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 100m, 200m, 100m, "GRN", 1, now,
|
||||
CancellationToken.None, postGlJournal: false);
|
||||
await db.SaveChangesAsync();
|
||||
Assert.Empty(gl.Posted);
|
||||
Assert.Single(db.StockLedger);
|
||||
|
||||
// Production and transfers have no document journal, so they keep the generic one.
|
||||
await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 5m, 10m, 105m, "TRF", 1, now,
|
||||
CancellationToken.None);
|
||||
var journal = Assert.Single(gl.Posted);
|
||||
Assert.Equal("STOCK", journal.SourceModule);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ internal sealed class TestCurrentUser : ICurrentUser
|
||||
|
||||
// Unauthenticated, so no role claim and no branch scoping: the three members the
|
||||
// AuthHex integration added to ICurrentUser.
|
||||
public int? RoleId => null;
|
||||
public string? RoleCode => null;
|
||||
public bool IsAdmin => false;
|
||||
public int? BranchId => null;
|
||||
|
||||
Reference in New Issue
Block a user